kit icon indicating copy to clipboard operation
kit copied to clipboard

`enhance` should progressively enhance `<form method="GET">`

Open geoffrich opened this issue 2 years ago • 16 comments

Describe the problem

Currently, enhance only works on forms where method="POST". I would also like to progressively enhance a form where method="GET", e.g. a search form.

For of an example of the kind of form where this would be useful, see the REPL search form on svelte.dev. We currently have to write the submit callback ourselves.

It would be nice if we could use:enhance here as well to get the same behavior. This would also let us encourage people to further #useThePlatform by teaching progressive enhancement of <form method="GET"> and <form method="POST">.

<form
	on:submit|preventDefault={(e) => {
		const search = new FormData(e.target).get('search');
		goto(search ? `/apps?search=${encodeURIComponent(search)}` : '/apps');
	}}
>
	<input
		type="search"
		placeholder="Search"
		aria-label="Search"
		name="search"
		value={data.search}
	/>
</form>

Describe the proposed solution

Instead of calling fetch, when method === 'GET' the action would construct the query string from the form data and call goto(action), i.e. call the load function for the given action. Since this is a navigation and no data is returned, applyAction would not be called and the form property would not be updated.

So on submit, the following form would call goto('/search?q=cats'), the same as the browser would do if JS was not available. We call any +page.js or +page.server.js load function and navigate to the page at that route (what goto normally does).

<form method="GET" action="/search" use:enhance>
	<input name="q" value="cats">
	<button>Submit</button>
</form>

enhance should also be updated to take <button formmethod="GET"> into account, likely using event.submitter.

I don't think a post-submit callback makes sense in this case, since it would trigger navigation. Any customizations could be implemented with $navigating, beforeNavigate, and afterNavigate.

One open question is do we allow customizing the options passed to goto? For instance, if the user wants to keep focus on the form with goto(action, { keepfocus: true }). Or do we have them implement the action themselves in that case?

Alternatives considered

Do nothing and have people implement it themselves.

Importance

nice to have

Additional Information

No response

geoffrich avatar Oct 12 '22 14:10 geoffrich

One thing I'm asking myself is if this should be part of the enhance action, because it's essentially two very separate things, and having it both in one means unnecessary code for people using only one of those variants.

dummdidumm avatar Oct 12 '22 15:10 dummdidumm

One thing I'm asking myself is if this should be part of the enhance action, because it's essentially two very separate things, and having it both in one means unnecessary code for people using only one of those variants.

dummdidumm avatar Oct 12 '22 15:10 dummdidumm

I can see it both ways.

Pros of splitting out:

  • we don't include unnecessary code (though I don't think it's that much code)
  • don't have to explain what happens with POST and what happens with GET when using the same action

Cons:

  • what do we call enhanceGet? This could be either an awkward name or a breaking change if we want to renmae the POST enhance to align with what we choose
  • user has to keep track of which action to use with GET and which one to use with POST. If you change the form method you have to import a completely different action instead of keeping <form use:enhance>
  • I don't think we could enhance a multi-method form (e.g. main form is method="POST" but there's a <button formmethod="GET">. if enhance handles both methods we could.

It's worth nothing that there is a good chunk of overlap in the code:

https://github.com/sveltejs/kit/blob/0f2e0d36b0ba5f3f06eff8be91998a566e1802aa/packages/kit/src/runtime/app/forms.js#L45-L59

geoffrich avatar Oct 12 '22 15:10 geoffrich

I can see it both ways.

Pros of splitting out:

  • we don't include unnecessary code (though I don't think it's that much code)
  • don't have to explain what happens with POST and what happens with GET when using the same action

Cons:

  • what do we call enhanceGet? This could be either an awkward name or a breaking change if we want to renmae the POST enhance to align with what we choose
  • user has to keep track of which action to use with GET and which one to use with POST. If you change the form method you have to import a completely different action instead of keeping <form use:enhance>
  • I don't think we could enhance a multi-method form (e.g. main form is method="POST" but there's a <button formmethod="GET">. if enhance handles both methods we could.

It's worth nothing that there is a good chunk of overlap in the code:

https://github.com/sveltejs/kit/blob/0f2e0d36b0ba5f3f06eff8be91998a566e1802aa/packages/kit/src/runtime/app/forms.js#L45-L59

geoffrich avatar Oct 12 '22 15:10 geoffrich

I think it should be in one (not splitted)... there are two reasons:

1st, we already tell if it's get or post via method= attribute, and telling it twice is weird. 2nd, we can have dynamic forms, e.g.

<script>
     export let forms = [];
</script>

{#each forms as form}
    <form method={form.method} action={form.action} use:enhance>
         {#each form.inputs as input}
              {input.text}<input type={input.type}>
         {/each}
         <button type="submit">Submit</button>
    </form>
{/each}

now imagine how would you do use:{form.method === 'get' ? enhanceGet : enhancePost} and even pass function to it, or some other things... it would be too complicated. In case of combined, it would be just use:enhance={form.handler}

gg187on avatar Oct 12 '22 17:10 gg187on

I think it should be in one (not splitted)... there are two reasons:

1st, we already tell if it's get or post via method= attribute, and telling it twice is weird. 2nd, we can have dynamic forms, e.g.

<script>
     export let forms = [];
</script>

{#each forms as form}
    <form method={form.method} action={form.action} use:enhance>
         {#each form.inputs as input}
              {input.text}<input type={input.type}>
         {/each}
         <button type="submit">Submit</button>
    </form>
{/each}

now imagine how would you do use:{form.method === 'get' ? enhanceGet : enhancePost} and even pass function to it, or some other things... it would be too complicated. In case of combined, it would be just use:enhance={form.handler}

gg187on avatar Oct 12 '22 17:10 gg187on

I agree with @dummdidumm:

it's essentially two very separate things

Submitting a GET form is just like clicking on a link. The difference between ?search=foobarbaz and ?page=2 is nil. A GET form submission is just a regular page navigation, handled by the relevant load function, not an Action. In the javascript-enabled case, the client side router handles each of these links just as it handles every other navigation. It already does what @geoffrich proposes. It intercepts the request and fetches the data rather than doing a full page reload.

cdcarson avatar Oct 13 '22 00:10 cdcarson

I agree with @dummdidumm:

it's essentially two very separate things

Submitting a GET form is just like clicking on a link. The difference between ?search=foobarbaz and ?page=2 is nil. A GET form submission is just a regular page navigation, handled by the relevant load function, not an Action. In the javascript-enabled case, the client side router handles each of these links just as it handles every other navigation. It already does what @geoffrich proposes. It intercepts the request and fetches the data rather than doing a full page reload.

cdcarson avatar Oct 13 '22 00:10 cdcarson

I like to use GET forms for requests with complex query parameters, such as filtering data tables.

This is simple GET forms that I implement my own GET enhance action since the brilliant idea of the form enhance action came up:

https://user-images.githubusercontent.com/25121822/195618138-6eed0bf0-30d5-456e-a14f-583f094412bf.mp4

and the form code is just:

image

transforming FormData to query search params can be done by passing it to URLSearchParams constructor and set value back to form controls can be easy using form.elements.

It's very convenient to use since form control elements can be anywhere even outside the form tag.

There is things to consider such as auto submit on select element changes that not default form behavior, but using flag like data-submitonchange per element is fine and for the keepfocus, data-keepfocus in form tag is also fine, I think.

Unfortunately, set value back to form controls is required Javascript-enabled, I hope this can be possible in SSR, on framework implementation.

mustofa-id avatar Oct 13 '22 23:10 mustofa-id

I like to use GET forms for requests with complex query parameters, such as filtering data tables.

This is simple GET forms that I implement my own GET enhance action since the brilliant idea of the form enhance action came up:

https://user-images.githubusercontent.com/25121822/195618138-6eed0bf0-30d5-456e-a14f-583f094412bf.mp4

and the form code is just:

image

transforming FormData to query search params can be done by passing it to URLSearchParams constructor and set value back to form controls can be easy using form.elements.

It's very convenient to use since form control elements can be anywhere even outside the form tag.

There is things to consider such as auto submit on select element changes that not default form behavior, but using flag like data-submitonchange per element is fine and for the keepfocus, data-keepfocus in form tag is also fine, I think.

Unfortunately, set value back to form controls is required Javascript-enabled, I hope this can be possible in SSR, on framework implementation.

mustofa-id avatar Oct 13 '22 23:10 mustofa-id

There is things to consider such as auto submit on select element changes that not default form behavior, but using flag like data-submitonchange per element is fine

you can use another action to submit on change or any other event

<script lang="ts">
	function submitNearestForm(this: HTMLElement) {
		this.closest('form')?.dispatchEvent(new SubmitEvent('submit', { bubbles: true }));
	}
</script>

<form use:enhance_get>
	<select name="test" on:input={submitNearestForm}>
		<option value="1">one</option>
		<option value="2">two</option>
		<option value="3">three</option>
	</select>
	<button type="submit">Submit</button>
</form>

david-plugge avatar Oct 17 '22 11:10 david-plugge

There is things to consider such as auto submit on select element changes that not default form behavior, but using flag like data-submitonchange per element is fine

you can use another action to submit on change or any other event

<script lang="ts">
	function submitNearestForm(this: HTMLElement) {
		this.closest('form')?.dispatchEvent(new SubmitEvent('submit', { bubbles: true }));
	}
</script>

<form use:enhance_get>
	<select name="test" on:input={submitNearestForm}>
		<option value="1">one</option>
		<option value="2">two</option>
		<option value="3">three</option>
	</select>
	<button type="submit">Submit</button>
</form>

david-plugge avatar Oct 17 '22 11:10 david-plugge

Submitting a GET form is just like clicking on a link. The difference between ?search=foobarbaz and ?page=2 is nil. A GET form submission is just a regular page navigation, handled by the relevant load function, not an Action. In the javascript-enabled case, the client side router handles each of these links just as it handles every other navigation. It already does what @geoffrich proposes. It intercepts the request and fetches the data rather than doing a full page reload.

That´s true, GET´s are very different. But they still belong to forms and the enhance helper does not tell you, atleast not by it´s name, that it´s only meant to be used with POST requests.

david-plugge avatar Oct 17 '22 11:10 david-plugge

Submitting a GET form is just like clicking on a link. The difference between ?search=foobarbaz and ?page=2 is nil. A GET form submission is just a regular page navigation, handled by the relevant load function, not an Action. In the javascript-enabled case, the client side router handles each of these links just as it handles every other navigation. It already does what @geoffrich proposes. It intercepts the request and fetches the data rather than doing a full page reload.

That´s true, GET´s are very different. But they still belong to forms and the enhance helper does not tell you, atleast not by it´s name, that it´s only meant to be used with POST requests.

david-plugge avatar Oct 17 '22 11:10 david-plugge

In the javascript-enabled case, the client side router handles each of these links just as it handles every other navigation

That made me think... Since we are dealing with basic navigation, should the router intercept form get requests?

With this bit of code i get global form get requests working just like anchor tags

// src/routes/+layout.svelte
onMount(() => {
	function handleSubmit(event: SubmitEvent) {
		const form = event.target;

		if (!(form instanceof HTMLFormElement)) {
			return;
		}

		const submitter = event.submitter as HTMLButtonElement | HTMLInputElement | null;

		const actionUrl = new URL(
			submitter?.hasAttribute('formaction') ? submitter.formAction : form.action
		);
		const reload =
			(submitter && submitter.getAttribute('data-sveltekit-reload') !== null) ||
			form.getAttribute('data-sveltekit-reload') !== null;

		// only handle get requests and same origin urls
		if (reload || form.method !== 'get' || actionUrl.origin !== location.origin) {
			return;
		}

		event.preventDefault();
		event.stopPropagation();
		const formData = new FormData(form);
		const query = new URLSearchParams(formData as any);
		goto(`?${query}`);
	}

	addEventListener('submit', handleSubmit);
	return () => {
		removeEventListener('submit', handleSubmit);
	};
});

We can opt out of this behavior just like with the links using data-sveltekit-reload

david-plugge avatar Oct 17 '22 12:10 david-plugge

In the javascript-enabled case, the client side router handles each of these links just as it handles every other navigation

That made me think... Since we are dealing with basic navigation, should the router intercept form get requests?

With this bit of code i get global form get requests working just like anchor tags

// src/routes/+layout.svelte
onMount(() => {
	function handleSubmit(event: SubmitEvent) {
		const form = event.target;

		if (!(form instanceof HTMLFormElement)) {
			return;
		}

		const submitter = event.submitter as HTMLButtonElement | HTMLInputElement | null;

		const actionUrl = new URL(
			submitter?.hasAttribute('formaction') ? submitter.formAction : form.action
		);
		const reload =
			(submitter && submitter.getAttribute('data-sveltekit-reload') !== null) ||
			form.getAttribute('data-sveltekit-reload') !== null;

		// only handle get requests and same origin urls
		if (reload || form.method !== 'get' || actionUrl.origin !== location.origin) {
			return;
		}

		event.preventDefault();
		event.stopPropagation();
		const formData = new FormData(form);
		const query = new URLSearchParams(formData as any);
		goto(`?${query}`);
	}

	addEventListener('submit', handleSubmit);
	return () => {
		removeEventListener('submit', handleSubmit);
	};
});

We can opt out of this behavior just like with the links using data-sveltekit-reload

david-plugge avatar Oct 17 '22 12:10 david-plugge

Yeah, I rather like the idea of treating <form> the same as <a> if there's no method="POST". A few tweaks to @david-plugge's code, to avoid the pollution issues we saw previously with enhance — we could just add this to client.js alongside the other event handling code, and voila:

target.addEventListener('submit', (event) => {
  const form = /** @type {HTMLFormElement} */ (
    HTMLFormElement.prototype.cloneNode.call(event.target)
  );

  const submitter = /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter);

  const method = event.submitter?.hasAttribute('formmethod')
    ? submitter.formMethod
    : form.method;

  if (method !== 'get') return;

  const action = new URL(
    event.submitter?.hasAttribute('formaction') ? submitter.formAction : form.action
  );

  if (is_external_url(action)) return;

  event.preventDefault();
  event.stopPropagation();
  action.search = new URLSearchParams(new FormData(event.target));
  goto(action, {});
});

Not pictured: taking account of data-sveltekit-reload

Rich-Harris avatar Nov 23 '22 03:11 Rich-Harris

Yeah, I rather like the idea of treating <form> the same as <a> if there's no method="POST". A few tweaks to @david-plugge's code, to avoid the pollution issues we saw previously with enhance — we could just add this to client.js alongside the other event handling code, and voila:

target.addEventListener('submit', (event) => {
  const form = /** @type {HTMLFormElement} */ (
    HTMLFormElement.prototype.cloneNode.call(event.target)
  );

  const submitter = /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter);

  const method = event.submitter?.hasAttribute('formmethod')
    ? submitter.formMethod
    : form.method;

  if (method !== 'get') return;

  const action = new URL(
    event.submitter?.hasAttribute('formaction') ? submitter.formAction : form.action
  );

  if (is_external_url(action)) return;

  event.preventDefault();
  event.stopPropagation();
  action.search = new URLSearchParams(new FormData(event.target));
  goto(action, {});
});

Not pictured: taking account of data-sveltekit-reload

Rich-Harris avatar Nov 23 '22 03:11 Rich-Harris

I think that should solve for everything in the original issue except this part:

One open question is do we allow customizing the options passed to goto? For instance, if the user wants to keep focus on the form with goto(action, { keepfocus: true }). Or do we have them implement the action themselves in that case?

Any ideas/API proposals on how we could allow passing those options? keepFocus seems like the most important, since I could easily see someone wanting a progressively enhanced search form that keeps focus on the search input after submission instead of resetting to the top of the page.

geoffrich avatar Nov 23 '22 16:11 geoffrich

I think that should solve for everything in the original issue except this part:

One open question is do we allow customizing the options passed to goto? For instance, if the user wants to keep focus on the form with goto(action, { keepfocus: true }). Or do we have them implement the action themselves in that case?

Any ideas/API proposals on how we could allow passing those options? keepFocus seems like the most important, since I could easily see someone wanting a progressively enhanced search form that keeps focus on the search input after submission instead of resetting to the top of the page.

geoffrich avatar Nov 23 '22 16:11 geoffrich

I don't know if this is the best possible approach, but you could do that with an autofocus on the search page. Or — if you only wanted it to apply after the form had been submitted — something like this:

afterNavigate(({ from, to }) => {
  if (from?.route.id === to.route.id) {
    input.focus();
  }
});

Better still, we add a new navigation type (which I think we'd need anyway):

afterNavigate(({ type }) => {
  if (type === 'form') { // or 'submit', or something else?
    input.focus();
  }
});

Rich-Harris avatar Nov 23 '22 16:11 Rich-Harris

I don't know if this is the best possible approach, but you could do that with an autofocus on the search page. Or — if you only wanted it to apply after the form had been submitted — something like this:

afterNavigate(({ from, to }) => {
  if (from?.route.id === to.route.id) {
    input.focus();
  }
});

Better still, we add a new navigation type (which I think we'd need anyway):

afterNavigate(({ type }) => {
  if (type === 'form') { // or 'submit', or something else?
    input.focus();
  }
});

Rich-Harris avatar Nov 23 '22 16:11 Rich-Harris

As for keeping the current scroll position, we could reuse data-sveltekit-noscroll.

Rich-Harris avatar Nov 23 '22 16:11 Rich-Harris

As for keeping the current scroll position, we could reuse data-sveltekit-noscroll.

Rich-Harris avatar Nov 23 '22 16:11 Rich-Harris

can you still process/validate for example search parameters when using the approach outlined by david/rich above?

it seems to my untrained eyes that the final URL is determined/used by:

  const action = new URL(
    event.submitter?.hasAttribute('formaction') ? submitter.formAction : form.action
  );
  
  ...
  
  goto(action, {});

if I need to validate the form input before I want to goto, how would I do that?

ollema avatar Nov 23 '22 16:11 ollema

can you still process/validate for example search parameters when using the approach outlined by david/rich above?

it seems to my untrained eyes that the final URL is determined/used by:

  const action = new URL(
    event.submitter?.hasAttribute('formaction') ? submitter.formAction : form.action
  );
  
  ...
  
  goto(action, {});

if I need to validate the form input before I want to goto, how would I do that?

ollema avatar Nov 23 '22 16:11 ollema

to give some more context - right now I have disabled the default action in my search/filter form and have something along the lines of this:

<script lang="ts>
  ...
	async function submitForm(form: HTMLFormElement) {
		const url = $page.url;

		const name = new FormData(form).get('name');
		const nameParam = name?.toString();
		if (nameParam) {
			url.searchParams.set('name', nameParam);
		} else {
			url.searchParams.delete('name');
		}

		const venue = new FormData(form).get('venue');
		const venueParam = venue?.toString();
		if (venueParam) {
			url.searchParams.set('venue', venueParam);
		} else {
			url.searchParams.delete('venue');
		}

		url.searchParams.delete('page');

		await prefetch(url.href);
		await goto(url.href, { noscroll: true, keepfocus: true });
	}

	let form: HTMLFormElement;

	let nameValue = $page.url.searchParams.get('name');
	let venueValue = $page.url.searchParams.get('venue');
</script>

<form on:submit|preventDefault={handleSubmit} bind:this={form}>
...

would something like this also work with the progressive GET form enhancement proposed earlier?

ollema avatar Nov 23 '22 16:11 ollema

to give some more context - right now I have disabled the default action in my search/filter form and have something along the lines of this:

<script lang="ts>
  ...
	async function submitForm(form: HTMLFormElement) {
		const url = $page.url;

		const name = new FormData(form).get('name');
		const nameParam = name?.toString();
		if (nameParam) {
			url.searchParams.set('name', nameParam);
		} else {
			url.searchParams.delete('name');
		}

		const venue = new FormData(form).get('venue');
		const venueParam = venue?.toString();
		if (venueParam) {
			url.searchParams.set('venue', venueParam);
		} else {
			url.searchParams.delete('venue');
		}

		url.searchParams.delete('page');

		await prefetch(url.href);
		await goto(url.href, { noscroll: true, keepfocus: true });
	}

	let form: HTMLFormElement;

	let nameValue = $page.url.searchParams.get('name');
	let venueValue = $page.url.searchParams.get('venue');
</script>

<form on:submit|preventDefault={handleSubmit} bind:this={form}>
...

would something like this also work with the progressive GET form enhancement proposed earlier?

ollema avatar Nov 23 '22 16:11 ollema

Yes, anything like this should work:

<form
  on:submit|preventDefault={event => {
    const data = new FormData(event.target);
    try {
      validate(data);
      const action = new URL(e.target.action);
      action.search = new URLSearchParams(data);
      goto(action);
    } catch(error) {
      doSomethingWith(error);
    }
  }}
>

SvelteKit's handler would need to bail on event.defaultPrevented.

By the way: there's no need to await prefetch(...) before calling goto(...), you can just use goto(...).

Rich-Harris avatar Nov 23 '22 16:11 Rich-Harris

Yes, anything like this should work:

<form
  on:submit|preventDefault={event => {
    const data = new FormData(event.target);
    try {
      validate(data);
      const action = new URL(e.target.action);
      action.search = new URLSearchParams(data);
      goto(action);
    } catch(error) {
      doSomethingWith(error);
    }
  }}
>

SvelteKit's handler would need to bail on event.defaultPrevented.

By the way: there's no need to await prefetch(...) before calling goto(...), you can just use goto(...).

Rich-Harris avatar Nov 23 '22 16:11 Rich-Harris

(I guess the question is whether we'd need that pattern often enough that it makes sense to use a GETified enhance after all)

Rich-Harris avatar Nov 23 '22 16:11 Rich-Harris

(I guess the question is whether we'd need that pattern often enough that it makes sense to use a GETified enhance after all)

Rich-Harris avatar Nov 23 '22 16:11 Rich-Harris