@@ -131,3 +131,5 @@ In theory, any API route may have a corresponding action route. | |||
- [ ] Documentation | |||
- [ ] Remix support | |||
- [ ] `accept-charset=""` attribute support | |||
- [X] Method override support | |||
- Integration with Next router (iceform-next) |
@@ -6,8 +6,8 @@ const ActionNotesResourcePage: NextPage = () => null; | |||
const getServerSideProps = Iceform.action.getServerSideProps({ | |||
fn: noteResource, | |||
onAction: async (context) => { | |||
if (context.req.method?.toLowerCase() === 'delete') { | |||
onAction: async (req, res) => { | |||
if (req.method?.toLowerCase() === 'delete') { | |||
return { | |||
redirect: { | |||
destination: '/notes', | |||
@@ -12,7 +12,7 @@ const GreetPage: Iceform.NextPage = ({ | |||
const [responseData, setResponseData] = React.useState<unknown>(); | |||
React.useEffect(() => { | |||
// response.bodyUsed might be undefined, so we use a strict comparison | |||
if (response?.bodyUsed === false) { | |||
if (response?.bodyUsed === false && response.status !== 204) { | |||
response?.json().then((responseData) => { | |||
setResponseData(responseData); | |||
}); | |||
@@ -1,5 +1,6 @@ | |||
import * as Iceform from '@modal-sh/iceform-next'; | |||
import * as React from 'react'; | |||
import { useRouter } from 'next/router'; | |||
export interface NotesItemPageProps { | |||
note: { | |||
@@ -15,15 +16,21 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||
res, | |||
note, | |||
}) => { | |||
const router = useRouter(); | |||
const body = (res.body ?? note ?? {}) as Record<string, unknown>; | |||
const {response, loading, ...isoformProps} = Iceform.useResponse({ | |||
const { | |||
response, | |||
loading, | |||
refresh: defaultRefresh, | |||
...isoformProps | |||
} = Iceform.useResponse({ | |||
res | |||
}); | |||
const [responseData, setResponseData] = React.useState<unknown>(); | |||
React.useEffect(() => { | |||
// response.bodyUsed might be undefined, so we use a strict comparison | |||
if (response?.bodyUsed === false) { | |||
if (response?.bodyUsed === false && response.status !== 204) { | |||
response?.json().then((responseData) => { | |||
setResponseData(responseData); | |||
}); | |||
@@ -31,38 +38,59 @@ const NotesItemPage: Iceform.NextPage<NotesItemPageProps> = ({ | |||
}, [response]); | |||
return ( | |||
<Iceform.Form | |||
{...isoformProps} | |||
method="post" | |||
action={`/a/notes/${req.query.noteId}`} | |||
clientAction={`/api/notes/${req.query.noteId}`} | |||
clientMethod="put" | |||
> | |||
<div> | |||
<label> | |||
<span className="after:block">Title</span> | |||
<input type="text" name="title" defaultValue={body.title as string} /> | |||
</label> | |||
</div> | |||
<div> | |||
<label> | |||
<span className="after:block">Image</span> | |||
<input type="file" name="image" /> | |||
</label> | |||
</div> | |||
<div> | |||
<label> | |||
<span className="after:block">Content</span> | |||
<textarea name="content" defaultValue={body.content as string} /> | |||
</label> | |||
</div> | |||
<div> | |||
<button type="submit">Submit</button> | |||
</div> | |||
</Iceform.Form> | |||
<div> | |||
<Iceform.Form | |||
{...isoformProps} | |||
className="contents" | |||
method="post" | |||
action={`/a/notes/${req.query.noteId}`} | |||
clientAction={`/api/notes/${req.query.noteId}`} | |||
clientMethod="put" | |||
refresh={defaultRefresh} | |||
> | |||
<div> | |||
<label> | |||
<span className="after:block">Title</span> | |||
<input type="text" name="title" defaultValue={body.title as string} /> | |||
</label> | |||
</div> | |||
<div> | |||
<label> | |||
<span className="after:block">Image</span> | |||
<input type="file" name="image" /> | |||
</label> | |||
</div> | |||
<div> | |||
<label> | |||
<span className="after:block">Content</span> | |||
<textarea name="content" defaultValue={body.content as string} /> | |||
</label> | |||
</div> | |||
<div> | |||
<button type="submit">Submit</button> | |||
</div> | |||
</Iceform.Form> | |||
<Iceform.Form | |||
{...isoformProps} | |||
method="post" | |||
action={`/a/notes/${req.query.noteId}`} | |||
clientAction={`/api/notes/${req.query.noteId}`} | |||
clientMethod="delete" | |||
className="contents" | |||
refresh={async (response) => { | |||
defaultRefresh(response); | |||
await router.push('/notes'); | |||
}} | |||
> | |||
<div> | |||
<button type="submit">Delete</button> | |||
</div> | |||
</Iceform.Form> | |||
</div> | |||
); | |||
}; | |||
// TODO type safety | |||
export const getServerSideProps = Iceform.destination.getServerSideProps({ | |||
fn: async (actionReq, actionRes, ctx) => { | |||
const {noteId} = ctx.query; | |||
@@ -1,12 +1,22 @@ | |||
import { NextPage } from 'next'; | |||
import * as Iceform from '@modal-sh/iceform-next'; | |||
import { useRouter } from 'next/router'; | |||
const NotesPage: NextPage = () => { | |||
const router = useRouter(); | |||
return ( | |||
<Iceform.Form | |||
method="post" | |||
action="/a/notes" | |||
clientAction="/api/notes" | |||
refresh={async (response) => { | |||
if (response.status !== 201) { | |||
return; | |||
} | |||
const { id } = await response.json(); | |||
await router.push(`/notes/${id}`); | |||
}} | |||
> | |||
<div> | |||
<label> | |||
@@ -13,7 +13,6 @@ | |||
"pridepack" | |||
], | |||
"devDependencies": { | |||
"@types/testing-library__jest-dom": "^5.14.9", | |||
"@testing-library/jest-dom": "^5.16.5", | |||
"@testing-library/react": "^13.4.0", | |||
"@testing-library/user-event": "^14.4.3", | |||
@@ -22,6 +21,7 @@ | |||
"@types/express": "^4.17.17", | |||
"@types/node": "^18.14.1", | |||
"@types/react": "^18.0.27", | |||
"@types/testing-library__jest-dom": "^5.14.9", | |||
"@vitest/coverage-v8": "^0.33.0", | |||
"eslint": "^8.35.0", | |||
"eslint-config-lxsmnsyc": "^0.5.0", | |||
@@ -69,7 +69,6 @@ | |||
"dependencies": { | |||
"@theoryofnekomata/formxtra": "^1.0.3", | |||
"busboy": "^1.6.0", | |||
"fetch-ponyfill": "^7.1.0", | |||
"nookies": "^2.5.2", | |||
"seroval": "^0.9.0" | |||
}, | |||
@@ -20,7 +20,7 @@ export type AllowedClientMethod = typeof ALLOWED_CLIENT_METHODS[number]; | |||
export type NextPage<T = NonNullable<unknown>, U = T> = DefaultNextPage< | |||
T & { | |||
res: NextApiResponse; | |||
req: NextApiRequest; | |||
}, U | |||
res: NextApiResponse; | |||
req: NextApiRequest; | |||
}, U | |||
> |
@@ -71,6 +71,8 @@ export const Form = React.forwardRef<FormDerivedElement, FormProps>(({ | |||
setServerMethodOverride(false); | |||
}, []); | |||
// TODO csrf token | |||
return ( | |||
<FormDerivedElementComponent | |||
{...etcProps} | |||
@@ -1,8 +1,8 @@ | |||
import * as React from 'react'; | |||
import fetchPonyfill from 'fetch-ponyfill'; | |||
import { EncTypeSerializerMap, serializeBody, SerializerOptions } from '../../utils/serialization'; | |||
import { ENCTYPE_MULTIPART_FORM_DATA } from '../../common/enctypes'; | |||
import { AllowedClientMethod, FormDerivedElement } from '../common'; | |||
import { METHODS_WITH_BODY } from '../../common/constants'; | |||
export interface UseFormFetchParams { | |||
clientAction?: string; | |||
@@ -37,7 +37,6 @@ export const useFormFetch = ({ | |||
if (clientAction) { | |||
invalidate?.(); | |||
const { fetch } = fetchPonyfill(); | |||
const headers: HeadersInit = { | |||
...(clientHeaders ?? {}), | |||
Accept: responseEncType, | |||
@@ -52,7 +51,9 @@ export const useFormFetch = ({ | |||
headers, | |||
}; | |||
if (!['GET', 'HEAD'].includes(clientMethod.toUpperCase())) { | |||
if ( | |||
METHODS_WITH_BODY.includes(clientMethod.toUpperCase() as typeof METHODS_WITH_BODY[number]) | |||
) { | |||
fetchInit.body = serializeBody({ | |||
form: event.currentTarget, | |||
encType, | |||
@@ -1,2 +1,3 @@ | |||
export const PREVENT_REDIRECT_FORM_KEY = '__iceform_prevent_redirect' as const; | |||
export const METHOD_FORM_KEY = '__iceform_method' as const; | |||
export const METHODS_WITH_BODY = ['POST', 'PUT', 'PATCH', 'DELETE'] as const; |
@@ -14,7 +14,7 @@ import { | |||
STATUS_MESSAGE_COOKIE_KEY, | |||
} from '../utils/cookies'; | |||
import { | |||
METHOD_FORM_KEY, | |||
METHOD_FORM_KEY, METHODS_WITH_BODY, | |||
PREVENT_REDIRECT_FORM_KEY, | |||
} from '../common/constants'; | |||
import { | |||
@@ -34,6 +34,8 @@ export type OnActionFunction< | |||
Params extends ParsedUrlQuery = ParsedUrlQuery, | |||
Preview extends PreviewData = PreviewData, | |||
> = ( | |||
actionReq: DefaultNextApiRequest, | |||
actionRes: IceformNextServerResponse, | |||
context: GetServerSidePropsContext<Params, Preview>, | |||
) => Promise<Awaited<ReturnType<GetServerSideProps<Props, Params, Preview>>> | undefined>; | |||
@@ -51,12 +53,13 @@ export interface ActionWrapperOptions { | |||
export const wrapApiHandler = ( | |||
options: ActionWrapperOptions, | |||
): NextApiHandler => async (req, res) => { | |||
const body = await deserializeBody({ | |||
req, | |||
deserializers: options.deserializers, | |||
}); | |||
const reqMut = req as unknown as Record<string, unknown>; | |||
reqMut.body = body; | |||
if (METHODS_WITH_BODY.includes(req.method?.toLowerCase() as typeof METHODS_WITH_BODY[number])) { | |||
reqMut.body = await deserializeBody({ | |||
req, | |||
deserializers: options.deserializers, | |||
}); | |||
} | |||
return options.fn(reqMut as unknown as DefaultNextApiRequest, res); | |||
}; | |||
@@ -109,7 +112,11 @@ export const getServerSideProps = ( | |||
} | |||
if (typeof options.onAction === 'function') { | |||
const onActionResult = await options.onAction(ctx); | |||
const onActionResult = await options.onAction( | |||
mockReq, | |||
mockRes, | |||
ctx, | |||
); | |||
if (onActionResult) { | |||
return onActionResult; | |||
} | |||
@@ -1,4 +1,3 @@ | |||
export const METHODS_WITH_BODY = ['POST', 'PUT', 'PATCH', 'DELETE'] as const; | |||
export const DEFAULT_METHOD = 'GET' as const; | |||
export const DEFAULT_ENCODING = 'utf-8' as const; | |||
export const ACTION_STATUS_CODE = 307 as const; // temporary redirect | |||
@@ -6,7 +6,6 @@ import { | |||
DEFAULT_ENCODING, | |||
DEFAULT_METHOD, | |||
DEFAULT_RESPONSE_STATUS_CODE, | |||
METHODS_WITH_BODY, | |||
} from './constants'; | |||
import { getBody } from '../utils/request'; | |||
import { | |||
@@ -16,6 +15,7 @@ import { | |||
STATUS_MESSAGE_COOKIE_KEY, | |||
} from '../utils/cookies'; | |||
import { ENCTYPE_APPLICATION_JSON, ENCTYPE_APPLICATION_OCTET_STREAM } from '../common/enctypes'; | |||
import { METHODS_WITH_BODY } from '../common/constants'; | |||
export type DestinationGetServerSideProps< | |||
Props extends Record<string, unknown> = Record<string, unknown>, | |||
@@ -6,6 +6,7 @@ import { | |||
ENCTYPE_X_WWW_FORM_URLENCODED, | |||
} from '../common/enctypes'; | |||
import { getBody, parseMultipartFormData } from './request'; | |||
import { METHOD_FORM_KEY, PREVENT_REDIRECT_FORM_KEY } from '../common/constants'; | |||
export type EncTypeSerializer = (data: unknown) => string; | |||
@@ -33,19 +34,31 @@ export const serializeBody = (params: SerializeBodyParams) => { | |||
} = params; | |||
if (encType === ENCTYPE_X_WWW_FORM_URLENCODED) { | |||
return new URLSearchParams(form); | |||
const searchParams = new URLSearchParams(form); | |||
searchParams.delete(METHOD_FORM_KEY); | |||
searchParams.delete(PREVENT_REDIRECT_FORM_KEY); | |||
return searchParams; | |||
} | |||
if (encType === ENCTYPE_MULTIPART_FORM_DATA) { | |||
// type error when provided a submitter element for some reason... | |||
const FormDataUnknown = FormData as unknown as { | |||
new(formElement?: HTMLElement, submitter?: HTMLElement): BodyInit; | |||
new(formElement?: HTMLElement, submitter?: HTMLElement): FormData; | |||
}; | |||
return new FormDataUnknown(form, options?.submitter); | |||
const formData = new FormDataUnknown(form, options?.submitter); | |||
formData.delete(METHOD_FORM_KEY); | |||
formData.delete(PREVENT_REDIRECT_FORM_KEY); | |||
return formData; | |||
} | |||
if (typeof serializers[encType] === 'function') { | |||
return serializers[encType](getFormValues(form, options)); | |||
const { | |||
[METHOD_FORM_KEY]: _method, | |||
[PREVENT_REDIRECT_FORM_KEY]: _preventRedirect, | |||
...formValues | |||
} = getFormValues(form, options); | |||
return serializers[encType](formValues); | |||
} | |||
throw new Error(`Unsupported encType: ${encType}`); | |||
@@ -14,9 +14,6 @@ importers: | |||
busboy: | |||
specifier: ^1.6.0 | |||
version: 1.6.0 | |||
fetch-ponyfill: | |||
specifier: ^7.1.0 | |||
version: 7.1.0 | |||
nookies: | |||
specifier: ^2.5.2 | |||
version: 2.5.2 | |||
@@ -3095,14 +3092,6 @@ packages: | |||
dependencies: | |||
reusify: 1.0.4 | |||
/fetch-ponyfill@7.1.0: | |||
resolution: {integrity: sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw==} | |||
dependencies: | |||
node-fetch: 2.6.13 | |||
transitivePeerDependencies: | |||
- encoding | |||
dev: false | |||
/file-entry-cache@6.0.1: | |||
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} | |||
engines: {node: ^10.12.0 || >=12.0.0} | |||
@@ -4141,18 +4130,6 @@ packages: | |||
- '@babel/core' | |||
- babel-plugin-macros | |||
/node-fetch@2.6.13: | |||
resolution: {integrity: sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==} | |||
engines: {node: 4.x || >=6.0.0} | |||
peerDependencies: | |||
encoding: ^0.1.0 | |||
peerDependenciesMeta: | |||
encoding: | |||
optional: true | |||
dependencies: | |||
whatwg-url: 5.0.0 | |||
dev: false | |||
/node-releases@2.0.13: | |||
resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} | |||
@@ -5207,10 +5184,6 @@ packages: | |||
url-parse: 1.5.10 | |||
dev: true | |||
/tr46@0.0.3: | |||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} | |||
dev: false | |||
/tr46@3.0.0: | |||
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} | |||
engines: {node: '>=12'} | |||
@@ -5566,10 +5539,6 @@ packages: | |||
defaults: 1.0.4 | |||
dev: true | |||
/webidl-conversions@3.0.1: | |||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} | |||
dev: false | |||
/webidl-conversions@7.0.0: | |||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} | |||
engines: {node: '>=12'} | |||
@@ -5595,13 +5564,6 @@ packages: | |||
webidl-conversions: 7.0.0 | |||
dev: true | |||
/whatwg-url@5.0.0: | |||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} | |||
dependencies: | |||
tr46: 0.0.3 | |||
webidl-conversions: 3.0.1 | |||
dev: false | |||
/which-boxed-primitive@1.0.2: | |||
resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} | |||
dependencies: | |||