form.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. import {useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Observer} from 'mobx-react';
  4. import {Button, ButtonProps} from 'sentry/components/button';
  5. import FormContext from 'sentry/components/forms/formContext';
  6. import FormModel, {FormOptions} from 'sentry/components/forms/model';
  7. import {Data, OnSubmitCallback} from 'sentry/components/forms/types';
  8. import Panel from 'sentry/components/panels/panel';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import {isRenderFunc} from 'sentry/utils/isRenderFunc';
  12. type RenderProps = {
  13. model: FormModel;
  14. };
  15. type RenderFunc = (props: RenderProps) => React.ReactNode;
  16. export interface FormProps
  17. extends Pick<
  18. FormOptions,
  19. | 'allowUndo'
  20. | 'resetOnError'
  21. | 'saveOnBlur'
  22. | 'apiEndpoint'
  23. | 'apiMethod'
  24. | 'onFieldChange'
  25. | 'onSubmitError'
  26. | 'onSubmitSuccess'
  27. > {
  28. additionalFieldProps?: {[key: string]: any};
  29. cancelLabel?: string;
  30. children?: React.ReactNode | RenderFunc;
  31. className?: string;
  32. 'data-test-id'?: string;
  33. extraButton?: React.ReactNode;
  34. footerClass?: string;
  35. footerStyle?: React.CSSProperties;
  36. hideFooter?: boolean;
  37. initialData?: Data;
  38. /**
  39. * A FormModel instance. If undefined a FormModel will be created for you.
  40. */
  41. model?: FormModel;
  42. /**
  43. * Callback fired when the form is cancelled via the cancel button.
  44. */
  45. onCancel?: (e: React.MouseEvent) => void;
  46. onPreSubmit?: () => void;
  47. /**
  48. * Callback to handle form submission.
  49. *
  50. * Defining this prop will replace the normal API submission behavior
  51. * and instead only call the provided callback.
  52. *
  53. * Your callback is expected to call `onSubmitSuccess` when the action succeeds and
  54. * `onSubmitError` when the action fails.
  55. */
  56. onSubmit?: OnSubmitCallback;
  57. /**
  58. * Ensure the form model isn't reset when the form unmounts
  59. */
  60. preventFormResetOnUnmount?: boolean;
  61. /**
  62. * Are changed required before the form can be submitted.
  63. */
  64. requireChanges?: boolean;
  65. /**
  66. * If set to true, preventDefault is not called
  67. */
  68. skipPreventDefault?: boolean;
  69. /**
  70. * Should the submit button be disabled.
  71. */
  72. submitDisabled?: boolean;
  73. submitLabel?: string;
  74. submitPriority?: ButtonProps['priority'];
  75. }
  76. function Form({
  77. 'data-test-id': dataTestId,
  78. allowUndo,
  79. apiEndpoint,
  80. apiMethod,
  81. cancelLabel,
  82. children,
  83. className,
  84. extraButton,
  85. footerClass,
  86. footerStyle,
  87. hideFooter,
  88. initialData,
  89. model,
  90. onCancel,
  91. onFieldChange,
  92. onPreSubmit,
  93. onSubmit,
  94. onSubmitError,
  95. onSubmitSuccess,
  96. preventFormResetOnUnmount,
  97. requireChanges,
  98. resetOnError,
  99. saveOnBlur,
  100. skipPreventDefault,
  101. submitDisabled,
  102. submitLabel,
  103. submitPriority,
  104. }: FormProps) {
  105. const [formModel] = useState(() => {
  106. const resolvedModel = model ?? new FormModel();
  107. // XXX(epurkhiser): We do this as part of the state construction to ensure
  108. // model data and options are set immediately
  109. //
  110. // TODO(epurkhiser): To have options and initialData be reactive properties
  111. // we'll have to make some changes to the cosnumers of models.
  112. if (initialData) {
  113. resolvedModel.setInitialData(initialData);
  114. }
  115. resolvedModel.setFormOptions({
  116. resetOnError,
  117. allowUndo,
  118. onFieldChange,
  119. onSubmitSuccess,
  120. onSubmitError,
  121. saveOnBlur,
  122. apiEndpoint,
  123. apiMethod,
  124. });
  125. return resolvedModel;
  126. });
  127. // Reset form model on un,out
  128. useEffect(
  129. () => () => {
  130. if (!preventFormResetOnUnmount) {
  131. formModel.reset();
  132. }
  133. },
  134. [formModel, preventFormResetOnUnmount]
  135. );
  136. const contextData = useMemo(
  137. () => ({saveOnBlur, form: formModel}),
  138. [formModel, saveOnBlur]
  139. );
  140. const handleSubmitSuccess = useCallback(
  141. data => {
  142. formModel.submitSuccess(data);
  143. onSubmitSuccess?.(data, formModel);
  144. },
  145. [formModel, onSubmitSuccess]
  146. );
  147. const handleSubmitError = useCallback(
  148. error => {
  149. formModel.submitError(error);
  150. onSubmitError?.(error, formModel);
  151. },
  152. [formModel, onSubmitError]
  153. );
  154. const handleSubmit = useCallback(
  155. e => {
  156. if (!skipPreventDefault) {
  157. e.preventDefault();
  158. }
  159. if (formModel.isSaving) {
  160. return;
  161. }
  162. onPreSubmit?.();
  163. onSubmit?.(
  164. formModel.getData(),
  165. handleSubmitSuccess,
  166. handleSubmitError,
  167. e,
  168. formModel
  169. );
  170. if (!onSubmit) {
  171. formModel.saveForm();
  172. }
  173. },
  174. [
  175. formModel,
  176. handleSubmitError,
  177. handleSubmitSuccess,
  178. onPreSubmit,
  179. onSubmit,
  180. skipPreventDefault,
  181. ]
  182. );
  183. const shouldShowFooter = typeof hideFooter !== 'undefined' ? !hideFooter : !saveOnBlur;
  184. return (
  185. <FormContext.Provider value={contextData}>
  186. <form
  187. onSubmit={handleSubmit}
  188. className={className ?? 'form-stacked'}
  189. data-test-id={dataTestId}
  190. >
  191. <div>
  192. {isRenderFunc<RenderFunc>(children) ? children({model: formModel}) : children}
  193. </div>
  194. {shouldShowFooter && (
  195. <StyledFooter
  196. className={footerClass}
  197. style={footerStyle}
  198. saveOnBlur={saveOnBlur}
  199. >
  200. {extraButton}
  201. <DefaultButtons>
  202. {onCancel && (
  203. <Observer>
  204. {() => (
  205. <Button
  206. disabled={formModel.isSaving}
  207. onClick={onCancel}
  208. style={{marginLeft: 5}}
  209. >
  210. {cancelLabel ?? t('Cancel')}
  211. </Button>
  212. )}
  213. </Observer>
  214. )}
  215. <Observer>
  216. {() => (
  217. <Button
  218. data-test-id="form-submit"
  219. priority={submitPriority ?? 'primary'}
  220. disabled={
  221. formModel.isError ||
  222. formModel.isSaving ||
  223. submitDisabled ||
  224. (requireChanges ? !formModel.formChanged : false)
  225. }
  226. type="submit"
  227. >
  228. {submitLabel ?? t('Save Changes')}
  229. </Button>
  230. )}
  231. </Observer>
  232. </DefaultButtons>
  233. </StyledFooter>
  234. )}
  235. </form>
  236. </FormContext.Provider>
  237. );
  238. }
  239. export default Form;
  240. const StyledFooter = styled('div')<{saveOnBlur?: boolean}>`
  241. display: flex;
  242. justify-content: flex-end;
  243. margin-top: 25px;
  244. border-top: 1px solid ${p => p.theme.innerBorder};
  245. background: none;
  246. padding: 16px 0 0;
  247. margin-bottom: 16px;
  248. ${p =>
  249. !p.saveOnBlur &&
  250. `
  251. ${Panel} & {
  252. margin-top: 0;
  253. padding-right: ${space(2)}
  254. }
  255. /* Better padding with form inside of a modal */
  256. [role='document'] & {
  257. padding-right: 30px;
  258. margin-left: -30px;
  259. margin-right: -30px;
  260. margin-bottom: -30px;
  261. margin-top: 16px;
  262. padding-bottom: 16px;
  263. }
  264. `};
  265. `;
  266. const DefaultButtons = styled('div')`
  267. display: grid;
  268. gap: ${space(1)};
  269. grid-auto-flow: column;
  270. justify-content: flex-end;
  271. flex: 1;
  272. `;