diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml new file mode 100644 index 00000000..6368d0ce --- /dev/null +++ b/.github/workflows/pre-release.yml @@ -0,0 +1,42 @@ +name: Prerelease + +on: + pull_request: + +jobs: + prerelease: + runs-on: ubuntu-latest + steps: + - name: Checkout all commits + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.7.0 + + - name: Use Node + uses: actions/setup-node@v4 + with: + node-version: 22 + # This doesn't just set the registry url, but also sets + # the right configuration in .npmrc that reads NPM token + # from NPM_AUTH_TOKEN environment variable. + # It actually creates a .npmrc in a temporary folder + # and sets the NPM_CONFIG_USERCONFIG environment variable. + registry-url: https://registry.npmjs.org + cache: 'pnpm' + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile + + - name: Build + shell: bash + run: pnpm --filter qwik build + + - run: pnpm dlx pkg-pr-new publish --pnpm ./packages/qwik + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # GITHUB_TOKEN is provided automatically in any repository diff --git a/packages/qwik/src/actions/formAction$.ts b/packages/qwik/src/actions/formAction$.ts index 443b24ab..3c9c5712 100644 --- a/packages/qwik/src/actions/formAction$.ts +++ b/packages/qwik/src/actions/formAction$.ts @@ -1,11 +1,12 @@ import { $, implicit$FirstArg, noSerialize, type QRL } from '@builder.io/qwik'; import { globalActionQrl, + routeActionQrl, type Action, type RequestEventAction, } from '@builder.io/qwik-city'; import { AbortMessage } from '@builder.io/qwik-city/middleware/request-handler'; -import { isDev } from '@builder.io/qwik/build'; +import { isDev, isServer } from '@builder.io/qwik/build'; import { decode } from 'decode-formdata'; import { FormError } from '../exceptions'; import type { @@ -26,7 +27,7 @@ import type { */ export type FormActionResult< TFieldValues extends FieldValues, - TResponseData extends ResponseData + TResponseData extends ResponseData, > = FormResponse & { errors?: Maybe>; }; @@ -36,7 +37,7 @@ export type FormActionResult< */ export type FormActionFunction< TFieldValues extends FieldValues, - TResponseData extends ResponseData + TResponseData extends ResponseData, > = ( values: TFieldValues, event: RequestEventAction @@ -44,121 +45,107 @@ export type FormActionFunction< /** * Value type of the second form action argument. + * @deprecated Use `FormDataOrValidation` instead. */ export type FormActionArg2 = + FormDataOrValidation; + +/** + * Value type of the second form action argument. + */ +export type FormDataOrValidation = | QRL> | (FormDataInfo & { validate: QRL>; }); /** - * See {@link formAction$} + * See {@link routeFormAction$} */ -export function formActionQrl< +export function routeFormActionQrl< TFieldValues extends FieldValues, - TResponseData extends ResponseData = undefined + TResponseData extends ResponseData = undefined, >( action: QRL>, - arg2: FormActionArg2 + dataOrValidation: FormDataOrValidation ): Action< FormActionStore, PartialValues, true > { - return globalActionQrl( + return routeActionQrl( $( async ( jsonData: unknown, event: RequestEventAction ): Promise> => { - // Destructure validate function and form data info - const { validate, ...formDataInfo } = - typeof arg2 === 'object' ? arg2 : { validate: arg2 }; - - // Get content type of request - const type = event.request.headers - .get('content-type') - ?.split(/[;,]/, 1)[0]; - - // Get form values from form or JSON data - const values: PartialValues = - type === 'application/x-www-form-urlencoded' || - type === 'multipart/form-data' - ? decode( - event.sharedMap.get('@actionFormData'), - formDataInfo, - ({ output }) => - output instanceof Blob ? noSerialize(output) : output - ) - : (jsonData as PartialValues); - - // Validate values and get errors if necessary - const errors = validate ? await validate(values) : {}; - - // Create form action store object - let formActionStore: FormActionStore = { - values, - errors, - response: {}, - }; - - // Try to run submit action if form has no errors - if (!Object.keys(errors).length) { - try { - const result = await action(values as TFieldValues, event); - - // Add result to form action store if necessary - if (result && typeof result === 'object') { - formActionStore = { - values, - errors: result.errors || {}, - response: { - status: result.status, - message: result.message, - data: result.data, - }, - }; - } - - // If an abort message was thrown (e.g. a redirect), forward it - } catch (error) { - if ( - error instanceof AbortMessage || - (isDev && - (error?.constructor?.name === 'AbortMessage' || - error?.constructor?.name === 'RedirectMessage')) - ) { - throw error; - - // Otherwise log error and set error response - } else { - console.error(error); - - // If it is an expected error, use its error info - if (error instanceof FormError) { - formActionStore = { - values, - errors: error.errors, - response: { - status: 'error', - message: error.message, - }, - }; - - // Otherwise return a generic message to avoid leaking - // sensetive information - } else { - formActionStore.response = { - status: 'error', - message: 'An unknown error has occurred.', - }; - } - } - } - } + return formActionLogic( + jsonData, + event, + action, + dataOrValidation + ); + } + ), + { + id: action.getHash(), + } + ); +} + +/** + + * Creates an action for progressively enhanced forms that handles validation + * and submission on the server. + * + * If you want to use it inside of a component, make sure you re-export it + * from either the `index.tsx` or `layout.tsx` that contains that component. + * see https://qwik.dev/docs/re-exporting-loaders/ for ho to do it. + * + * @param action The server action function. + * @param dataOrValidation Validation and/or form data info. + * + * @returns Form action constructor. - // Return form action store object - return formActionStore; + */ +export const routeFormAction$: < + TFieldValues extends FieldValues, + TResponseData extends ResponseData = undefined, +>( + actionQrl: FormActionFunction, + dataOrValidation: FormDataOrValidation +) => Action< + FormActionStore, + PartialValues, + true +> = implicit$FirstArg(routeFormActionQrl); + +/** + * See {@link globalFormAction$} + */ +export function globalFormActionQrl< + TFieldValues extends FieldValues, + TResponseData extends ResponseData = undefined, +>( + action: QRL>, + dataOrValidation: FormDataOrValidation +): Action< + FormActionStore, + PartialValues, + true +> { + return globalActionQrl( + $( + async ( + jsonData: unknown, + event: RequestEventAction + ): Promise> => { + return formActionLogic( + jsonData, + event, + action, + dataOrValidation + ); } ), { @@ -167,6 +154,36 @@ export function formActionQrl< ); } +/** + + * If you need a form action that is route protected (by auth), use `routeFormAction$` instead. + * + * Creates an action for progressively enhanced forms that handles validation + * and submission on the server. + * + * @param action The server action function. + * @param dataOrValidation Validation and/or form data info. + * + * @returns Form action constructor. + + */ +export const globalFormAction$: < + TFieldValues extends FieldValues, + TResponseData extends ResponseData = undefined, +>( + actionQrl: FormActionFunction, + dataOrValidation: FormDataOrValidation +) => Action< + FormActionStore, + PartialValues, + true +> = implicit$FirstArg(globalFormActionQrl); + +/** + * See {@link formAction$} + */ +export const formActionQrl = globalFormActionQrl; + /** * Creates an action for progressively enhanced forms that handles validation * and submission on the server. @@ -175,15 +192,121 @@ export function formActionQrl< * @param arg2 Validation and/or form data info. * * @returns Form action constructor. + + * @deprecated Use `routeFormAction$` (recommended) or `globalFormAction$` instead. */ export const formAction$: < TFieldValues extends FieldValues, - TResponseData extends ResponseData = undefined + TResponseData extends ResponseData = undefined, >( actionQrl: FormActionFunction, - arg2: FormActionArg2 + dataOrValidation: FormActionArg2 ) => Action< FormActionStore, PartialValues, true > = implicit$FirstArg(formActionQrl); + +/** + * @internal + */ +export async function formActionLogic< + TFieldValues extends FieldValues, + TResponseData extends ResponseData = undefined, +>( + jsonData: unknown, + event: RequestEventAction, + action: QRL>, + dataOrValidation: FormDataOrValidation +) { + // Destructure validate function and form data info + const { validate, ...formDataInfo } = + typeof dataOrValidation === 'object' + ? dataOrValidation + : { validate: dataOrValidation }; + + // Get content type of request + const type = event.request.headers.get('content-type')?.split(/[;,]/, 1)[0]; + + // Get form values from form or JSON data + const values: PartialValues = + type === 'application/x-www-form-urlencoded' || + type === 'multipart/form-data' + ? decode( + event.sharedMap.get('@actionFormData'), + formDataInfo, + ({ output }) => + output instanceof Blob ? noSerialize(output) : output + ) + : (jsonData as PartialValues); + + // Validate values and get errors if necessary + const errors = validate ? await validate(values) : {}; + + // Create form action store object + let formActionStore: FormActionStore = { + values, + errors, + response: {}, + }; + + if (isServer) { + // Try to run submit action if form has no errors + if (!Object.keys(errors).length) { + try { + const result = await action(values as TFieldValues, event); + + // Add result to form action store if necessary + if (result && typeof result === 'object') { + formActionStore = { + values, + errors: result.errors || {}, + response: { + status: result.status, + message: result.message, + data: result.data, + }, + }; + } + + // If an abort message was thrown (e.g. a redirect), forward it + } catch (error) { + if ( + error instanceof AbortMessage || + (isDev && + (error?.constructor?.name === 'AbortMessage' || + error?.constructor?.name === 'RedirectMessage')) + ) { + throw error; + + // Otherwise log error and set error response + } else { + console.error(error); + + // If it is an expected error, use its error info + if (error instanceof FormError) { + formActionStore = { + values, + errors: error.errors, + response: { + status: 'error', + message: error.message, + }, + }; + + // Otherwise return a generic message to avoid leaking + // sensetive information + } else { + formActionStore.response = { + status: 'error', + message: 'An unknown error has occurred.', + }; + } + } + } + } + } + + // Return form action store object + return formActionStore; +}