diff --git a/packages/docs/src/routes/api/qwik-city/api.json b/packages/docs/src/routes/api/qwik-city/api.json index 084b18048c8..310774e9e1b 100644 --- a/packages/docs/src/routes/api/qwik-city/api.json +++ b/packages/docs/src/routes/api/qwik-city/api.json @@ -54,7 +54,7 @@ } ], "kind": "TypeAlias", - "content": "```typescript\nexport type ActionStore = {\n readonly actionPath: string;\n readonly isRunning: boolean;\n readonly status?: number;\n readonly formData: FormData | undefined;\n readonly value: RETURN | undefined;\n readonly submit: QRL Promise> : (form: INPUT | FormData | SubmitEvent) => Promise>>;\n};\n```\n**References:** [ActionReturn](#actionreturn)", + "content": "```typescript\nexport type ActionStore = {\n readonly actionPath: string;\n readonly isRunning: boolean;\n readonly status?: number;\n readonly formData: FormData | undefined;\n readonly value: RETURN | undefined;\n readonly submit: QRL Promise> : (form: INPUT | FormData | SubmitEvent) => Promise>>;\n readonly submitted: boolean;\n};\n```\n**References:** [ActionReturn](#actionreturn)", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/runtime/src/types.ts", "mdFile": "qwik-city.actionstore.md" }, @@ -250,7 +250,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface FormProps extends Omit \n```\n**Extends:** Omit<QwikJSX.IntrinsicElements\\['form'\\], 'action' \\| 'method'>\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n[action?](#)\n\n\n\n\n\n\n\n[ActionStore](#actionstore)<O, I, true \\| false>\n\n\n\n\n_(Optional)_ Reference to the action returned by `action()`.\n\n\n
\n\n[key?](#)\n\n\n\n\n\n\n\nstring \\| number \\| null\n\n\n\n\n_(Optional)_\n\n\n
\n\n[onSubmit$?](#)\n\n\n\n\n\n\n\n(event: Event, form: HTMLFormElement) => ValueOrPromise<void>\n\n\n\n\n_(Optional)_ Event handler executed right when the form is submitted.\n\n\n
\n\n[onSubmitCompleted$?](#)\n\n\n\n\n\n\n\n(event: CustomEvent<[FormSubmitCompletedDetail](#formsubmitsuccessdetail)<O>>, form: HTMLFormElement) => ValueOrPromise<void>\n\n\n\n\n_(Optional)_ Event handler executed right after the action is executed successfully and returns some data.\n\n\n
\n\n[reloadDocument?](#)\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_ When `true` the form submission will cause a full page reload, even if SPA mode is enabled and JS is available.\n\n\n
\n\n[spaReset?](#)\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_ When `true` all the form inputs will be reset in SPA mode, just like happens in a full page form submission.\n\nDefaults to `false`\n\n\n
", + "content": "```typescript\nexport interface FormProps extends Omit \n```\n**Extends:** Omit<QwikJSX.IntrinsicElements\\['form'\\], 'action' \\| 'method'>\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n[action?](#)\n\n\n\n\n\n\n\n[ActionStore](#actionstore)<O, I, true \\| false>\n\n\n\n\n_(Optional)_ Reference to the action returned by `action()`.\n\n\n
\n\n[key?](#)\n\n\n\n\n\n\n\nstring \\| number \\| null\n\n\n\n\n_(Optional)_\n\n\n
\n\n[onSubmit$?](#)\n\n\n\n\n\n\n\nQRLEventHandlerMulti<SubmitEvent, HTMLFormElement> \\| undefined\n\n\n\n\n_(Optional)_ Event handler executed right when the form is submitted.\n\n\n
\n\n[onSubmitCompleted$?](#)\n\n\n\n\n\n\n\nQRLEventHandlerMulti<CustomEvent<[FormSubmitCompletedDetail](#formsubmitsuccessdetail)<O>>, HTMLFormElement> \\| undefined\n\n\n\n\n_(Optional)_ Event handler executed right after the action is executed successfully and returns some data.\n\n\n
\n\n[reloadDocument?](#)\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_ When `true` the form submission will cause a full page reload, even if SPA mode is enabled and JS is available.\n\n\n
\n\n[spaReset?](#)\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_ When `true` all the form inputs will be reset in SPA mode, just like happens in a full page form submission.\n\nDefaults to `false`\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/runtime/src/form-component.tsx", "mdFile": "qwik-city.formprops.md" }, diff --git a/packages/docs/src/routes/api/qwik-city/index.md b/packages/docs/src/routes/api/qwik-city/index.md index 7b82ecbfdae..cdd1405f765 100644 --- a/packages/docs/src/routes/api/qwik-city/index.md +++ b/packages/docs/src/routes/api/qwik-city/index.md @@ -165,6 +165,7 @@ export type ActionStore = { ? (form?: INPUT | FormData | SubmitEvent) => Promise> : (form: INPUT | FormData | SubmitEvent) => Promise> >; + readonly submitted: boolean; }; ``` @@ -1264,7 +1265,7 @@ _(Optional)_ -(event: Event, form: HTMLFormElement) => ValueOrPromise<void> +QRLEventHandlerMulti<SubmitEvent, HTMLFormElement> \| undefined @@ -1279,7 +1280,7 @@ _(Optional)_ Event handler executed right when the form is submitted. -(event: CustomEvent<[FormSubmitCompletedDetail](#formsubmitsuccessdetail)<O>>, form: HTMLFormElement) => ValueOrPromise<void> +QRLEventHandlerMulti<CustomEvent<[FormSubmitCompletedDetail](#formsubmitsuccessdetail)<O>>, HTMLFormElement> \| undefined diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index dfb62646924..00a2e7799a9 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -1704,7 +1704,7 @@ } ], "kind": "Function", - "content": "> This API is provided as an alpha preview for developers and may change based on feedback that we receive. Do not use this API in a production environment.\n> \n\nLoad the prefetch graph for the container.\n\nEach Qwik container needs to include its own prefetch graph.\n\n\n```typescript\nPrefetchGraph: (opts?: {\n base?: string;\n manifestHash?: string;\n manifestURL?: string;\n}) => import(\"@builder.io/qwik/jsx-runtime\").JSXNode<\"script\">\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nopts\n\n\n\n\n{ base?: string; manifestHash?: string; manifestURL?: string; }\n\n\n\n\n_(Optional)_ Options for the loading prefetch graph.\n\n- `base` - Base of the graph. For a default installation this will default to `/build/`. But if more than one MFE is installed on the page, then each MFE needs to have its own base. - `manifestHash` - Hash of the manifest file to load. If not provided the hash will be extracted from the container attribute `q:manifest-hash` and assume the default build file `${base}/q-bundle-graph-${manifestHash}.json`. - `manifestURL` - URL of the manifest file to load if non-standard bundle graph location name.\n\n\n
\n**Returns:**\n\nimport(\"@builder.io/qwik/jsx-runtime\").JSXNode<\"script\">", + "content": "> This API is provided as an alpha preview for developers and may change based on feedback that we receive. Do not use this API in a production environment.\n> \n\nLoad the prefetch graph for the container.\n\nEach Qwik container needs to include its own prefetch graph.\n\n\n```typescript\nPrefetchGraph: (opts?: {\n base?: string;\n manifestHash?: string;\n manifestURL?: string;\n}) => import(\"@builder.io/qwik/jsx-runtime\").JSXNode<\"script\">\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nopts\n\n\n\n\n{ base?: string; manifestHash?: string; manifestURL?: string; }\n\n\n\n\n_(Optional)_ Options for the loading prefetch graph.\n\n- `base` - Base of the graph. For a default installation this will default to `/build/`. But if more than one MFE is installed on the page, then each MFE needs to have its own base. - `manifestHash` - Hash of the manifest file to load. If not provided the hash will be extracted from the container attribute `q:manifest-hash` and assume the default build file `${base}/q-bundle-graph-${manifestHash}.json`. - `manifestURL` - URL of the manifest file to load if non-standard bundle graph location name.\n\n\n
\n**Returns:**\n\nimport(\"@builder.io/qwik/jsx-runtime\").[JSXNode](#jsxnode)<\"script\">", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/components/prefetch.ts", "mdFile": "qwik.prefetchgraph.md" }, @@ -1718,7 +1718,7 @@ } ], "kind": "Function", - "content": "> This API is provided as an alpha preview for developers and may change based on feedback that we receive. Do not use this API in a production environment.\n> \n\nInstall a service worker which will prefetch the bundles.\n\nThere can only be one service worker per page. Because there can be many separate Qwik Containers on the page each container needs to load its prefetch graph using `PrefetchGraph` component.\n\n\n```typescript\nPrefetchServiceWorker: (opts: {\n base?: string;\n path?: string;\n verbose?: boolean;\n fetchBundleGraph?: boolean;\n}) => import(\"@builder.io/qwik/jsx-runtime\").JSXNode<\"script\">\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nopts\n\n\n\n\n{ base?: string; path?: string; verbose?: boolean; fetchBundleGraph?: boolean; }\n\n\n\n\nOptions for the prefetch service worker.\n\n- `base` - Base URL for the service worker. - `path` - Path to the service worker.\n\n\n
\n**Returns:**\n\nimport(\"@builder.io/qwik/jsx-runtime\").JSXNode<\"script\">", + "content": "> This API is provided as an alpha preview for developers and may change based on feedback that we receive. Do not use this API in a production environment.\n> \n\nInstall a service worker which will prefetch the bundles.\n\nThere can only be one service worker per page. Because there can be many separate Qwik Containers on the page each container needs to load its prefetch graph using `PrefetchGraph` component.\n\n\n```typescript\nPrefetchServiceWorker: (opts: {\n base?: string;\n path?: string;\n verbose?: boolean;\n fetchBundleGraph?: boolean;\n}) => import(\"@builder.io/qwik/jsx-runtime\").JSXNode<\"script\">\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nopts\n\n\n\n\n{ base?: string; path?: string; verbose?: boolean; fetchBundleGraph?: boolean; }\n\n\n\n\nOptions for the prefetch service worker.\n\n- `base` - Base URL for the service worker. - `path` - Path to the service worker.\n\n\n
\n**Returns:**\n\nimport(\"@builder.io/qwik/jsx-runtime\").[JSXNode](#jsxnode)<\"script\">", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/components/prefetch.ts", "mdFile": "qwik.prefetchserviceworker.md" }, diff --git a/packages/docs/src/routes/api/qwik/index.md b/packages/docs/src/routes/api/qwik/index.md index 4b0efa2cdd8..ac0e7dc6cf0 100644 --- a/packages/docs/src/routes/api/qwik/index.md +++ b/packages/docs/src/routes/api/qwik/index.md @@ -3520,7 +3520,7 @@ _(Optional)_ Options for the loading prefetch graph. **Returns:** -import("@builder.io/qwik/jsx-runtime").JSXNode<"script"> +import("@builder.io/qwik/jsx-runtime").[JSXNode](#jsxnode)<"script"> [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/components/prefetch.ts) @@ -3572,7 +3572,7 @@ Options for the prefetch service worker. **Returns:** -import("@builder.io/qwik/jsx-runtime").JSXNode<"script"> +import("@builder.io/qwik/jsx-runtime").[JSXNode](#jsxnode)<"script"> [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/components/prefetch.ts) diff --git a/packages/qwik-city/runtime/src/api.md b/packages/qwik-city/runtime/src/api.md index 9c85597a2b9..124880e93b8 100644 --- a/packages/qwik-city/runtime/src/api.md +++ b/packages/qwik-city/runtime/src/api.md @@ -13,6 +13,7 @@ import type { EnvGetter } from '@builder.io/qwik-city/middleware/request-handler import { JSXNode } from '@builder.io/qwik'; import { JSXOutput } from '@builder.io/qwik'; import { QRL } from '@builder.io/qwik'; +import { QRLEventHandlerMulti } from '@builder.io/qwik'; import { QwikIntrinsicElements } from '@builder.io/qwik'; import { QwikJSX } from '@builder.io/qwik'; import type { ReadonlySignal } from '@builder.io/qwik'; @@ -23,7 +24,7 @@ import { RequestEventCommon } from '@builder.io/qwik-city/middleware/request-han import { RequestEventLoader } from '@builder.io/qwik-city/middleware/request-handler'; import { RequestHandler } from '@builder.io/qwik-city/middleware/request-handler'; import type { ResolveSyncValue } from '@builder.io/qwik-city/middleware/request-handler'; -import { ValueOrPromise } from '@builder.io/qwik'; +import type { ValueOrPromise } from '@builder.io/qwik'; import { z } from 'zod'; import type * as zod from 'zod'; @@ -68,6 +69,7 @@ export type ActionStore = { readonly formData: FormData | undefined; readonly value: RETURN | undefined; readonly submit: QRL Promise> : (form: INPUT | FormData | SubmitEvent) => Promise>>; + readonly submitted: boolean; }; // @public (undocumented) @@ -219,8 +221,8 @@ export interface FormProps extends Omit; // (undocumented) key?: string | number | null; - onSubmit$?: (event: Event, form: HTMLFormElement) => ValueOrPromise; - onSubmitCompleted$?: (event: CustomEvent>, form: HTMLFormElement) => ValueOrPromise; + onSubmit$?: QRLEventHandlerMulti | undefined; + onSubmitCompleted$?: QRLEventHandlerMulti>, HTMLFormElement> | undefined; reloadDocument?: boolean; spaReset?: boolean; } diff --git a/packages/qwik-city/runtime/src/form-component.tsx b/packages/qwik-city/runtime/src/form-component.tsx index 2ee04cade53..270b39573c3 100644 --- a/packages/qwik-city/runtime/src/form-component.tsx +++ b/packages/qwik-city/runtime/src/form-component.tsx @@ -1,10 +1,11 @@ import { jsx, _wrapSignal, - type QwikJSX, - type ValueOrPromise, component$, Slot, + $, + type QwikJSX, + type QRLEventHandlerMulti, } from '@builder.io/qwik'; import type { ActionStore } from './types'; import { useNavigate } from './use-functions'; @@ -36,13 +37,12 @@ export interface FormProps spaReset?: boolean; /** Event handler executed right when the form is submitted. */ - onSubmit$?: (event: Event, form: HTMLFormElement) => ValueOrPromise; + onSubmit$?: QRLEventHandlerMulti | undefined; /** Event handler executed right after the action is executed successfully and returns some data. */ - onSubmitCompleted$?: ( - event: CustomEvent>, - form: HTMLFormElement - ) => ValueOrPromise; + onSubmitCompleted$?: + | QRLEventHandlerMulti>, HTMLFormElement> + | undefined; key?: string | number | null; } @@ -53,13 +53,44 @@ export const Form = ( key: string | null ) => { if (action) { + const isArrayApi = Array.isArray(onSubmit$); + // if you pass an array you can choose where you want action.submit in it + if (isArrayApi) { + return jsx( + 'form', + { + ...rest, + action: action.actionPath, + 'preventdefault:submit': !reloadDocument, + onSubmit$: [ + ...onSubmit$, + // action.submit "submitcompleted" event for onSubmitCompleted$ events + !reloadDocument + ? $((evt: SubmitEvent) => { + if (!action.submitted) { + return action.submit(evt); + } + }) + : undefined, + ], + method: 'post', + ['data-spa-reset']: spaReset ? 'true' : undefined, + }, + key + ); + } return jsx( 'form', { ...rest, action: action.actionPath, 'preventdefault:submit': !reloadDocument, - onSubmit$: [!reloadDocument ? action.submit : undefined, onSubmit$], + onSubmit$: [ + // action.submit "submitcompleted" event for onSubmitCompleted$ events + !reloadDocument ? action.submit : undefined, + // TODO: v2 breaking change this should fire before the action.submit + onSubmit$, + ], method: 'post', ['data-spa-reset']: spaReset ? 'true' : undefined, }, @@ -87,15 +118,19 @@ export const GetForm = component$>( preventdefault:submit={!reloadDocument} data-spa-reset={spaReset ? 'true' : undefined} {...rest} - onSubmit$={async (_, form) => { - const formData = new FormData(form); - const params = new URLSearchParams(); - formData.forEach((value, key) => { - if (typeof value === 'string') { - params.append(key, value); - } - }); - nav('?' + params.toString(), { type: 'form', forceReload: true }).then(() => { + onSubmit$={[ + ...(Array.isArray(onSubmit$) ? onSubmit$ : [onSubmit$]), + $(async (_evt, form) => { + const formData = new FormData(form); + const params = new URLSearchParams(); + formData.forEach((value, key) => { + if (typeof value === 'string') { + params.append(key, value); + } + }); + await nav('?' + params.toString(), { type: 'form', forceReload: true }); + }), + $((_evt, form) => { if (form.getAttribute('data-spa-reset') === 'true') { form.reset(); } @@ -109,8 +144,10 @@ export const GetForm = component$>( }, }) ); - }); - }} + // + }), + // end of array + ]} > diff --git a/packages/qwik-city/runtime/src/server-functions.ts b/packages/qwik-city/runtime/src/server-functions.ts index 8acae378a41..1f31b01a272 100644 --- a/packages/qwik-city/runtime/src/server-functions.ts +++ b/packages/qwik-city/runtime/src/server-functions.ts @@ -57,6 +57,7 @@ export const routeActionQrl = (( const currentAction = useAction(); const initialState: Editable>> = { actionPath: `?${QACTION_KEY}=${id}`, + submitted: false, isRunning: false, status: undefined, value: undefined, @@ -104,6 +105,7 @@ Action.run() can only be called on the browser, for example when a user clicks a if (data instanceof FormData) { state.formData = data; } + state.submitted = true; state.isRunning = true; loc.isNavigating = true; currentAction.value = { diff --git a/packages/qwik-city/runtime/src/types.ts b/packages/qwik-city/runtime/src/types.ts index 913307bf7e2..072f5ff7dd2 100644 --- a/packages/qwik-city/runtime/src/types.ts +++ b/packages/qwik-city/runtime/src/types.ts @@ -677,6 +677,8 @@ export type ActionStore = { ? (form?: INPUT | FormData | SubmitEvent) => Promise> : (form: INPUT | FormData | SubmitEvent) => Promise> >; + /** Is action.submit was submitted */ + readonly submitted: boolean; }; type Failed = { diff --git a/starters/apps/qwikcity-test/src/routes/(common)/actions/multiple-handlers/index.tsx b/starters/apps/qwikcity-test/src/routes/(common)/actions/multiple-handlers/index.tsx new file mode 100644 index 00000000000..8e0dd5fc08b --- /dev/null +++ b/starters/apps/qwikcity-test/src/routes/(common)/actions/multiple-handlers/index.tsx @@ -0,0 +1,50 @@ +import { $, component$, useSignal } from "@builder.io/qwik"; +import { Form, routeAction$ } from "@builder.io/qwik-city"; + +export const useDotNotationAction = routeAction$(async (payload) => { + return { + success: true, + payload: payload, + }; +}); + +export default component$(() => { + const finished = useSignal(false); + const dotNotation = useDotNotationAction(); + + return ( + <> +

Dot Notation Form Inputs

+
{ + finished.value = false; + }), + $((evt) => dotNotation.submit(evt)), + $(() => { + finished.value = dotNotation.submitted; + }), + ]} + id="dot-notation-form" + > + + + + + + + + +
+ {dotNotation.value?.success && ( +
+ {JSON.stringify(dotNotation.value.payload)} +
+ )} +
{String(finished.value)}
+ + ); +}); diff --git a/starters/e2e/qwikcity/actions.spec.ts b/starters/e2e/qwikcity/actions.spec.ts index 88e3eb0c46e..2c997637364 100644 --- a/starters/e2e/qwikcity/actions.spec.ts +++ b/starters/e2e/qwikcity/actions.spec.ts @@ -3,12 +3,12 @@ import { expect, test } from "@playwright/test"; test.describe("actions", () => { test.describe("mpa", () => { test.use({ javaScriptEnabled: false }); - tests(); + MPA_and_SPA_tests(); }); test.describe("spa", () => { test.use({ javaScriptEnabled: true }); - tests(); + MPA_and_SPA_tests(); test.describe("issue4679", () => { test("should serialize Form without action", async ({ page }) => { @@ -19,9 +19,23 @@ test.describe("actions", () => { await expect(button).toHaveText("Toggle True"); }); }); + test.describe("multiple-handlers", () => { + test("should allow multiple handlers", async ({ page }) => { + await page.goto("/qwikcity-test/actions/multiple-handlers/"); + const success = page.locator("#multiple-handlers-success"); + + await expect(success).toBeHidden(); + await page.locator("#multiple-handlers-button").click(); + await expect(success).toHaveText( + '{"arrayOld":["0","1"],"arrayNew":["0","1"],"people":[{"name":"Fred"},{"name":"Sam"}]}', + ); + const finished = page.locator("#multiple-handlers-finished"); + await expect(finished).toContainText("true"); + }); + }); }); - function tests() { + function MPA_and_SPA_tests() { test.describe("login form", () => { test.beforeEach(async ({ page }) => { await page.goto("/qwikcity-test/actions/");