diff --git a/README.md b/README.md index d980029..ba135b6 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ In theory, any API route may have a corresponding action route. - [X] Content negotiation (custom request data) - [ ] `<form method="dialog">` (on hold, see https://github.com/whatwg/html/issues/9625) - [ ] Tests - - [ ] Form with redirects + - [X] Form with redirects - [ ] Form with files - [ ] Documentation - [ ] Remix support diff --git a/packages/iceform-next-sandbox/.gitignore b/packages/iceform-next-sandbox/.gitignore index 8f322f0..bc7c3e7 100644 --- a/packages/iceform-next-sandbox/.gitignore +++ b/packages/iceform-next-sandbox/.gitignore @@ -33,3 +33,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.db/ diff --git a/packages/iceform-next-sandbox/src/handlers/note.ts b/packages/iceform-next-sandbox/src/handlers/note.ts index 93d9d66..55ed12f 100644 --- a/packages/iceform-next-sandbox/src/handlers/note.ts +++ b/packages/iceform-next-sandbox/src/handlers/note.ts @@ -214,6 +214,9 @@ const createNote = (params: NoteCollectionParams): NextApiHandler => async (req, } // how to genericize the location URL? + // TODO check if req.url only returns the path of this API + // req.url returns '/a/notes' when accessed from /a/notes, so we need to associate each action + // route to each API route res.setHeader('Location', `${params.basePath}/${newId}`); res.status(201).json({ diff --git a/packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts b/packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts index 0d28175..9f7d994 100644 --- a/packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts +++ b/packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts @@ -2,7 +2,7 @@ import {NextPage} from 'next'; import * as Iceform from '@modal-sh/iceform-next'; import {noteResource} from '@/handlers/note'; -const ActionNotesIndexPage: NextPage = () => null; +const ActionNotesResourcePage: NextPage = () => null; const getServerSideProps = Iceform.action.getServerSideProps({ fn: noteResource, @@ -10,5 +10,5 @@ const getServerSideProps = Iceform.action.getServerSideProps({ export { getServerSideProps, - ActionNotesIndexPage as default, + ActionNotesResourcePage as default, }; diff --git a/packages/iceform-next-sandbox/src/pages/a/notes/index.ts b/packages/iceform-next-sandbox/src/pages/a/notes/index.ts index cfd6e2a..a20a35d 100644 --- a/packages/iceform-next-sandbox/src/pages/a/notes/index.ts +++ b/packages/iceform-next-sandbox/src/pages/a/notes/index.ts @@ -2,15 +2,25 @@ import {NextPage} from 'next'; import * as Iceform from '@modal-sh/iceform-next'; import {noteCollection} from '@/handlers/note'; -const ActionNotesIndexPage: NextPage = () => null; +const ActionNotesCollectionPage: NextPage = () => null; + +// this serves as an ID to associate the action URL to the API URL +const RESOURCE_BASE_PATH = '/api/notes'; const getServerSideProps = Iceform.action.getServerSideProps({ fn: noteCollection({ - basePath: '/api/notes' + basePath: RESOURCE_BASE_PATH }), + mapLocationToRedirectDestination: (referer, url) => { + const resourceBaseUrl = `${RESOURCE_BASE_PATH}/`; + if (url.startsWith(resourceBaseUrl)) { + return `/notes/${url.slice(resourceBaseUrl.length)}`; + } + return referer; + }, }); export { getServerSideProps, - ActionNotesIndexPage as default, + ActionNotesCollectionPage as default, }; diff --git a/packages/iceform-next-sandbox/src/pages/api/notes/index.ts b/packages/iceform-next-sandbox/src/pages/api/notes/index.ts index 249cbac..45b7cc1 100644 --- a/packages/iceform-next-sandbox/src/pages/api/notes/index.ts +++ b/packages/iceform-next-sandbox/src/pages/api/notes/index.ts @@ -1,9 +1,12 @@ import * as Iceform from '@modal-sh/iceform-next'; import { noteCollection } from '@/handlers/note'; +// this serves as an ID to associate the action URL to the API URL +const RESOURCE_BASE_PATH = '/api/notes'; + const handler = Iceform.action.wrapApiHandler({ fn: noteCollection({ - basePath: '/api/notes' + basePath: RESOURCE_BASE_PATH, }), }); diff --git a/packages/iceform-next-sandbox/src/pages/greet.tsx b/packages/iceform-next-sandbox/src/pages/greet.tsx index 6e272af..00ee4f0 100644 --- a/packages/iceform-next-sandbox/src/pages/greet.tsx +++ b/packages/iceform-next-sandbox/src/pages/greet.tsx @@ -5,7 +5,9 @@ const GreetPage: Iceform.NextPage = ({ req, res, }) => { - const {response, ...isoformProps} = Iceform.useResponse(res); + const {response, loading, ...isoformProps} = Iceform.useResponse({ + res + }); const [responseData, setResponseData] = React.useState<unknown>(); React.useEffect(() => { diff --git a/packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx b/packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx index b6511b8..18efec1 100644 --- a/packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx +++ b/packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx @@ -1 +1,57 @@ -// TODO view note page +import * as Iceform from '@modal-sh/iceform-next'; +import * as React from 'react'; + +const NotesItemPage: Iceform.NextPage = ({ + req, + res, +}) => { + const body = (res.body ?? {}) as Record<string, unknown>; + const {response, loading, ...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) { + response?.json().then((responseData) => { + setResponseData(responseData); + }); + } + }, [response]); + + return ( + <Iceform.Form + {...isoformProps} + method="post" + action={`/a/notes/${req.query.noteId}`} + clientAction={`/api/notes/${req.query.noteId}`} + > + <div> + <label> + <span>Title</span> + <input type="text" name="title" defaultValue={body.title as string} /> + </label> + </div> + <div> + <label> + <span>Image</span> + <input type="file" name="image" /> + </label> + </div> + <div> + <label> + <span>Content</span> + <textarea name="content" defaultValue={body.content as string} /> + </label> + </div> + <div> + <button type="submit">Submit</button> + </div> + </Iceform.Form> + ) +}; + +export const getServerSideProps = Iceform.destination.getServerSideProps(); + +export default NotesItemPage; diff --git a/packages/iceform-next-sandbox/src/pages/notes/index.tsx b/packages/iceform-next-sandbox/src/pages/notes/index.tsx index fa51247..c0d8db7 100644 --- a/packages/iceform-next-sandbox/src/pages/notes/index.tsx +++ b/packages/iceform-next-sandbox/src/pages/notes/index.tsx @@ -1 +1,36 @@ -// TODO view all notes/add notes page +import { NextPage } from 'next'; +import * as Iceform from '@modal-sh/iceform-next'; + +const NotesPage: NextPage = () => { + return ( + <Iceform.Form + method="post" + action="/a/notes" + clientAction="/api/notes" + > + <div> + <label> + <span>Title</span> + <input type="text" name="title" /> + </label> + </div> + <div> + <label> + <span>Image</span> + <input type="file" name="image" /> + </label> + </div> + <div> + <label> + <span>Content</span> + <textarea name="content" /> + </label> + </div> + <div> + <button type="submit">Submit</button> + </div> + </Iceform.Form> + ) +}; + +export default NotesPage; diff --git a/packages/iceform-next/src/client/hooks/useResponse.ts b/packages/iceform-next/src/client/hooks/useResponse.ts index eb1fd38..967b729 100644 --- a/packages/iceform-next/src/client/hooks/useResponse.ts +++ b/packages/iceform-next/src/client/hooks/useResponse.ts @@ -10,22 +10,27 @@ export const useResponse = (params: UseResponseParams) => { const [response, setResponse] = React.useState<Response | undefined>( res.body ? new Response(res.body as unknown as BodyInit) : undefined, ); + const [loading, setLoading] = React.useState(false); const invalidate = React.useCallback(() => { setResponse(undefined); + setLoading(true); }, []); const refresh = React.useCallback((newResponse: Response) => { setResponse(newResponse); + setLoading(false); }, []); return React.useMemo(() => ({ response, refresh, invalidate, + loading, }), [ response, refresh, invalidate, + loading, ]); }; diff --git a/packages/iceform-next/src/server/constants.ts b/packages/iceform-next/src/server/constants.ts new file mode 100644 index 0000000..f033ead --- /dev/null +++ b/packages/iceform-next/src/server/constants.ts @@ -0,0 +1,8 @@ +export const METHODS_WITH_BODY = ['POST', 'PUT', 'PATCH', 'DELETE'] as const; +export const PREVENT_REDIRECT_FORM_KEY = '__iceform_prevent_redirect' 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 +export const DEFAULT_RESPONSE_STATUS_CODE = 200 as const; // ok +export const CONTENT_TYPE_HEADER_KEY = 'content-type' as const; +export const LOCATION_HEADER_KEY = 'location' as const; diff --git a/packages/iceform-next/src/server.ts b/packages/iceform-next/src/server/index.ts similarity index 64% rename from packages/iceform-next/src/server.ts rename to packages/iceform-next/src/server/index.ts index 7e7f374..ea6ff1c 100644 --- a/packages/iceform-next/src/server.ts +++ b/packages/iceform-next/src/server/index.ts @@ -2,47 +2,60 @@ import { GetServerSideProps, NextApiHandler, NextApiRequest as DefaultNextApiRequest, - NextApiResponse as DefaultNextApiResponse, PageConfig, } from 'next'; -import { deserialize, serialize } from 'seroval'; +import { deserialize } from 'seroval'; import { NextApiResponse, NextApiRequest, -} from './common/types'; +} from '../common/types'; import { ENCTYPE_APPLICATION_JSON, ENCTYPE_APPLICATION_OCTET_STREAM, -} from './common/enctypes'; -import { getBody } from './utils/request'; +} from '../common/enctypes'; +import { getBody } from '../utils/request'; import { CookieManager, BODY_COOKIE_KEY, STATUS_CODE_COOKIE_KEY, STATUS_MESSAGE_COOKIE_KEY, CONTENT_TYPE_COOKIE_KEY, -} from './utils/cookies'; -import { deserializeBody, EncTypeDeserializerMap } from './utils/serialization'; +} from '../utils/cookies'; +import { deserializeBody, EncTypeDeserializerMap } from '../utils/serialization'; +import { IceformNextServerResponse } from './response'; +import { + DEFAULT_METHOD, + DEFAULT_ENCODING, + DEFAULT_RESPONSE_STATUS_CODE, + ACTION_STATUS_CODE, + METHODS_WITH_BODY, + PREVENT_REDIRECT_FORM_KEY, +} from './constants'; export namespace destination { export const getServerSideProps = ( gspFn?: GetServerSideProps, ): GetServerSideProps => async (ctx) => { const req: NextApiRequest = { - query: ctx.query, + query: { + ...ctx.query, + ...(ctx.params ?? {}), + }, body: null, }; - const { method = 'GET' } = ctx.req; + const { method = DEFAULT_METHOD } = ctx.req; - if (!['GET', 'HEAD'].includes(method.toUpperCase())) { + if (METHODS_WITH_BODY.includes(method.toUpperCase() as typeof METHODS_WITH_BODY[number])) { const body = await getBody(ctx.req); - req.body = body.toString('utf-8'); + req.body = body.toString(DEFAULT_ENCODING); } const cookieManager = new CookieManager(ctx); // TODO how to properly remove cookies without leftovers? if (cookieManager.hasCookie(STATUS_CODE_COOKIE_KEY)) { - ctx.res.statusCode = Number(cookieManager.getCookie(STATUS_CODE_COOKIE_KEY) || '200'); + ctx.res.statusCode = Number( + cookieManager.getCookie(STATUS_CODE_COOKIE_KEY) || DEFAULT_RESPONSE_STATUS_CODE, + ); cookieManager.unsetCookie(STATUS_CODE_COOKIE_KEY); } @@ -103,6 +116,11 @@ export namespace action { export interface ActionWrapperOptions { fn: NextApiHandler, deserializers?: EncTypeDeserializerMap, + /** + * Maps the Location header from the handler response to an accessible URL. + * @param url + */ + mapLocationToRedirectDestination?: (referer: string, url: string) => string, } export const wrapApiHandler = ( @@ -120,7 +138,7 @@ export namespace action { export const getServerSideProps = ( options: ActionWrapperOptions, ): GetServerSideProps => async (ctx) => { - const { referer } = ctx.req.headers; + const { referer = '/' } = ctx.req.headers; const mockReq = { ...ctx.req, @@ -130,56 +148,39 @@ export namespace action { }), } as DefaultNextApiRequest; - let data: unknown = null; - let contentType: string | undefined; - const mockRes = { - // todo handle other nextapiresponse methods (e.g. setting headers, writeHead, etc.) - statusMessage: '', - statusCode: 200, - status(code: number) { - // should we mask error status code to Bad Gateway? - this.statusCode = code; - return mockRes; - }, - send: (raw?: unknown) => { - // xtodo: how to transfer binary response in a more compact way? - // > we let seroval handle this for now - if (typeof raw === 'undefined' || raw === null) { - return; - } - - if (raw instanceof Buffer) { - contentType = ENCTYPE_APPLICATION_OCTET_STREAM; - } - - data = serialize(raw); - }, - json: (raw: unknown) => { - contentType = ENCTYPE_APPLICATION_JSON; - data = serialize(raw); - }, - } as DefaultNextApiResponse; - + const mockRes = new IceformNextServerResponse(ctx.req); await options.fn(mockReq, mockRes); const cookieManager = new CookieManager(ctx); cookieManager.setCookie(STATUS_CODE_COOKIE_KEY, mockRes.statusCode.toString()); cookieManager.setCookie(STATUS_MESSAGE_COOKIE_KEY, mockRes.statusMessage); - if (data) { - cookieManager.setCookie(BODY_COOKIE_KEY, data as string); - if (contentType) { - cookieManager.setCookie(CONTENT_TYPE_COOKIE_KEY, contentType); + if (mockRes.data) { + cookieManager.setCookie(BODY_COOKIE_KEY, mockRes.data as string); + if (mockRes.contentType) { + cookieManager.setCookie(CONTENT_TYPE_COOKIE_KEY, mockRes.contentType); } } + const preventRedirect = ( + typeof mockReq.body === 'object' + && mockReq.body !== null + && PREVENT_REDIRECT_FORM_KEY in mockReq.body + ); + const redirectDestination = ( + mockRes.location + && typeof options.mapLocationToRedirectDestination === 'function' + && !preventRedirect + ) + ? options.mapLocationToRedirectDestination(referer, mockRes.location) + : referer; return { redirect: { - destination: referer, - statusCode: 307, + destination: redirectDestination, + statusCode: ACTION_STATUS_CODE, }, props: { query: ctx.query, - body: data, + body: mockRes.data, }, }; }; diff --git a/packages/iceform-next/src/server/response.ts b/packages/iceform-next/src/server/response.ts new file mode 100644 index 0000000..9d9bc6f --- /dev/null +++ b/packages/iceform-next/src/server/response.ts @@ -0,0 +1,93 @@ +import { ServerResponse } from 'http'; +import { NextApiResponse as DefaultNextApiResponse } from 'next/dist/shared/lib/utils'; +import { serialize } from 'seroval'; +import { ENCTYPE_APPLICATION_JSON, ENCTYPE_APPLICATION_OCTET_STREAM } from '../common/enctypes'; +import { ACTION_STATUS_CODE, CONTENT_TYPE_HEADER_KEY, LOCATION_HEADER_KEY } from './constants'; + +class DummyServerResponse {} + +const EffectiveServerResponse = ServerResponse ?? DummyServerResponse; + +export class IceformNextServerResponse + extends EffectiveServerResponse + implements DefaultNextApiResponse { + data?: unknown; + + contentType?: string; + + location?: string; + + private readonly revalidateResponse = Promise.resolve(undefined); + + setHeader(name: string, value: number | string | readonly string[]): this { + super.setHeader(name, value); + + if (name.toLowerCase() === CONTENT_TYPE_HEADER_KEY) { + this.contentType = value.toString(); + } + + if (name.toLowerCase() === LOCATION_HEADER_KEY) { + this.location = value.toString(); + } + + return this; + } + + clearPreviewData(): DefaultNextApiResponse { + // unused + return this as unknown as DefaultNextApiResponse; + } + + json(body: unknown): void { + this.contentType = ENCTYPE_APPLICATION_JSON; + this.data = serialize(body); + } + + revalidate(): Promise<void> { + // unused + return this.revalidateResponse; + } + + send(body: unknown): void { + // xtodo: how to transfer binary response in a more compact way? + // > we let seroval handle this for now + if (typeof body === 'undefined' || body === null) { + return; + } + + if (body instanceof Buffer) { + this.contentType = ENCTYPE_APPLICATION_OCTET_STREAM; + } + + this.data = serialize(body); + } + + setDraftMode(): DefaultNextApiResponse { + // unused + return this as unknown as DefaultNextApiResponse; + } + + setPreviewData(): DefaultNextApiResponse { + // unused + return this as unknown as DefaultNextApiResponse; + } + + status(statusCode: number): DefaultNextApiResponse { + this.statusCode = statusCode; + return this as unknown as DefaultNextApiResponse; + } + + strictContentLength = true; + + redirect(...args: [string] | [number, string]): DefaultNextApiResponse { + const [arg1, arg2] = args; + if (typeof arg1 === 'number' && typeof arg2 === 'string') { + this.statusCode = arg1; + this.setHeader('Location', arg2); + } else if (typeof arg1 === 'string') { + this.statusCode = ACTION_STATUS_CODE; + this.setHeader('Location', arg1); + } + return this as unknown as DefaultNextApiResponse; + } +} diff --git a/packages/iceform-next/src/utils/request.ts b/packages/iceform-next/src/utils/request.ts index e047b97..f147a80 100644 --- a/packages/iceform-next/src/utils/request.ts +++ b/packages/iceform-next/src/utils/request.ts @@ -38,9 +38,10 @@ export const parseMultipartFormData = async ( }); file.on('close', () => { - body[name] = new File([fileData.buffer], filename, { - type: mimetype, - }); + const newFile = fileData.buffer as unknown as Record<string, unknown>; + newFile.name = filename; + newFile.type = mimetype; + body[name] = newFile; }); });