Use forms with or without client-side JavaScript--no code duplication required!
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

169 lines
4.3 KiB

  1. import * as React from 'react';
  2. import { getFormValues } from '@theoryofnekomata/formxtra';
  3. import fetchPonyfill from 'fetch-ponyfill';
  4. import {NextPage as DefaultNextPage} from 'next';
  5. import {NextApiResponse, NextApiRequest} from './common';
  6. const FormDerivedElementComponent = 'form' as const;
  7. type FormDerivedElement = HTMLElementTagNameMap[typeof FormDerivedElementComponent];
  8. type BaseProps = React.HTMLProps<FormDerivedElement>;
  9. type EncTypeSerializer = (data: unknown) => string;
  10. type EncTypeSerializerMap = Record<string, EncTypeSerializer>;
  11. const DEFAULT_ENCTYPE_SERIALIZERS: EncTypeSerializerMap = {
  12. 'application/json': (data: unknown) => JSON.stringify(data),
  13. };
  14. type GetFormValuesOptions = Parameters<typeof getFormValues>[1];
  15. interface FormProps extends Omit<BaseProps, 'action'> {
  16. action?: string;
  17. clientAction?: string;
  18. clientHeaders?: HeadersInit;
  19. clientMethod?: BaseProps['method'];
  20. invalidate?: (...args: unknown[]) => unknown;
  21. refresh?: (response: Response) => void;
  22. encTypeSerializers?: EncTypeSerializerMap;
  23. responseEncType?: string;
  24. serializerOptions?: GetFormValuesOptions;
  25. }
  26. export const useResponse = (res: NextApiResponse) => {
  27. const [response, setResponse] = React.useState<Response | undefined>(
  28. res.body ? new Response(res.body as any) : undefined
  29. );
  30. const onStale = React.useCallback(() => {
  31. setResponse(undefined);
  32. }, []);
  33. const onFresh = React.useCallback((response: Response) => {
  34. setResponse(response);
  35. }, []);
  36. return React.useMemo(() => ({
  37. response,
  38. refresh: onFresh,
  39. invalidate: onStale,
  40. }), [
  41. response,
  42. ]);
  43. };
  44. interface SerializeBodyParams {
  45. form: HTMLFormElement,
  46. encType: string,
  47. serializers: EncTypeSerializerMap,
  48. options?: GetFormValuesOptions,
  49. }
  50. const serializeBody = (params: SerializeBodyParams) => {
  51. const {
  52. form,
  53. encType,
  54. serializers,
  55. options
  56. } = params;
  57. if (encType === 'multipart/form-data') {
  58. // type error when provided a submitter element for some reason...
  59. const FormDataUnknown = FormData as unknown as {
  60. new(form?: HTMLElement, submitter?: HTMLElement ): BodyInit;
  61. };
  62. return new FormDataUnknown(form, options?.submitter);
  63. }
  64. if (encType === 'application/x-www-form-urlencoded') {
  65. return new URLSearchParams(form);
  66. }
  67. if (typeof serializers[encType] === 'function') {
  68. return serializers[encType](getFormValues(form, options));
  69. }
  70. throw new Error(`Unsupported encType: ${encType}`);
  71. }
  72. export const Form = React.forwardRef<FormDerivedElement, FormProps>(({
  73. children,
  74. onSubmit,
  75. action,
  76. method = 'get',
  77. clientAction = action,
  78. clientMethod = method,
  79. clientHeaders,
  80. encType = 'multipart/form-data',
  81. invalidate,
  82. refresh,
  83. encTypeSerializers = DEFAULT_ENCTYPE_SERIALIZERS,
  84. responseEncType = 'application/json',
  85. serializerOptions,
  86. ...etcProps
  87. }, forwardedRef) => {
  88. const handleSubmit: React.FormEventHandler<FormDerivedElement> = async (event) => {
  89. event.preventDefault();
  90. const nativeEvent = event.nativeEvent as unknown as { submitter?: HTMLElement };
  91. if (clientAction) {
  92. invalidate?.();
  93. const { fetch } = fetchPonyfill();
  94. const headers: HeadersInit = {
  95. ...(clientHeaders ?? {}),
  96. 'Accept': responseEncType,
  97. };
  98. if (encType !== 'multipart/form-data') {
  99. // browser automatically generates content-type header for multipart/form-data
  100. (headers as unknown as Record<string, string>)['Content-Type'] = encType;
  101. }
  102. const response = await fetch(clientAction, {
  103. method: clientMethod.toUpperCase(),
  104. body: serializeBody({
  105. form: event.currentTarget,
  106. encType,
  107. serializers: encTypeSerializers,
  108. options: {
  109. ...serializerOptions,
  110. submitter: nativeEvent.submitter,
  111. },
  112. }),
  113. headers,
  114. });
  115. refresh?.(response);
  116. }
  117. onSubmit?.(event);
  118. };
  119. // TODO how to display put/patch method in form? HTML only supports get/post
  120. // > throw error when not get/post
  121. // TODO handle "dialog" method as invalid
  122. const serverMethodRaw = method.toLowerCase();
  123. const serverMethod = serverMethodRaw === 'get' ? 'get' : 'post';
  124. const serverEncType = 'multipart/form-data';
  125. return (
  126. <FormDerivedElementComponent
  127. {...etcProps}
  128. ref={forwardedRef}
  129. onSubmit={handleSubmit}
  130. action={action}
  131. method={serverMethod}
  132. encType={serverEncType}
  133. >
  134. {children}
  135. </FormDerivedElementComponent>
  136. );
  137. });
  138. export type NextPage<T = {}, U = T> = DefaultNextPage<
  139. T & {
  140. res: NextApiResponse;
  141. req: NextApiRequest;
  142. }, U
  143. >