index.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. import {Component, Fragment} from 'react';
  2. import {Observer} from 'mobx-react';
  3. import Alert from 'sentry/components/alert';
  4. import Button from 'sentry/components/button';
  5. import PanelAlert from 'sentry/components/panels/panelAlert';
  6. import {t} from 'sentry/locale';
  7. import {defined} from 'sentry/utils';
  8. import {sanitizeQuerySelector} from 'sentry/utils/sanitizeQuerySelector';
  9. import Field from '../field';
  10. import FieldControl from '../field/fieldControl';
  11. import FieldErrorReason from '../field/fieldErrorReason';
  12. import {FieldGroupProps} from '../field/types';
  13. import FormContext from '../formContext';
  14. import FormModel, {MockModel} from '../model';
  15. import {FieldValue} from '../types';
  16. import FormFieldControlState from './controlState';
  17. /**
  18. * Some fields don't need to implement their own onChange handlers, in
  19. * which case we will receive an Event, but if they do we should handle
  20. * the case where they return a value as the first argument.
  21. */
  22. const getValueFromEvent = (valueOrEvent?: FieldValue | MouseEvent, e?: MouseEvent) => {
  23. const event = e || valueOrEvent;
  24. const value = defined(e) ? valueOrEvent : event?.target?.value;
  25. return {value, event};
  26. };
  27. /**
  28. * This is a list of field properties that can accept a function taking the
  29. * form model, that will be called to determine the value of the prop upon an
  30. * observed change in the model.
  31. *
  32. * This uses mobx's observation of the models observable fields.
  33. */
  34. // !!Warning!! - the order of these props matters, as they are checked in order that they appear.
  35. // One instance of a test that relies on this order is accountDetails.spec.tsx.
  36. const propsToObserve = ['help', 'highlighted', 'inline', 'visible', 'disabled'] as const;
  37. interface FormFieldPropModel extends FormFieldProps {
  38. model: FormModel;
  39. }
  40. type ObservedFn<_P, T> = (props: FormFieldPropModel) => T;
  41. type ObservedFnOrValue<P, T> = T | ObservedFn<P, T>;
  42. type ObservedPropResolver = [
  43. typeof propsToObserve[number],
  44. () => ResolvedObservableProps[typeof propsToObserve[number]]
  45. ];
  46. /**
  47. * Construct the type for properties that may be given observed functions
  48. */
  49. interface ObservableProps {
  50. disabled?: ObservedFnOrValue<{}, FieldGroupProps['disabled']>;
  51. help?: ObservedFnOrValue<{}, FieldGroupProps['help']>;
  52. highlighted?: ObservedFnOrValue<{}, FieldGroupProps['highlighted']>;
  53. inline?: ObservedFnOrValue<{}, FieldGroupProps['inline']>;
  54. visible?: ObservedFnOrValue<{}, FieldGroupProps['visible']>;
  55. }
  56. /**
  57. * The same ObservableProps, once they have been resolved
  58. */
  59. interface ResolvedObservableProps {
  60. disabled?: FieldGroupProps['disabled'];
  61. help?: FieldGroupProps['help'];
  62. highlighted?: FieldGroupProps['highlighted'];
  63. inline?: FieldGroupProps['inline'];
  64. visible?: FieldGroupProps['visible'];
  65. }
  66. // XXX(epurkhiser): Many of these props are duplicated in form types. The forms
  67. // interfaces need some serious consolidation
  68. interface BaseProps {
  69. /**
  70. * Used to render the actual control
  71. */
  72. children: (renderProps) => React.ReactNode;
  73. /**
  74. * Name of the field
  75. */
  76. name: string;
  77. // TODO(ts): These are actually props that are needed for some lower
  78. // component. We should let the rendering component pass these in instead
  79. defaultValue?: FieldValue;
  80. formatMessageValue?: boolean | Function;
  81. /**
  82. * Transform data when saving on blur.
  83. */
  84. getData?: (value: any) => any;
  85. /**
  86. * Should hide error message?
  87. */
  88. hideErrorMessage?: boolean;
  89. onBlur?: (value, event) => void;
  90. onChange?: (value, event) => void;
  91. onKeyDown?: (value, event) => void;
  92. placeholder?: ObservedFnOrValue<{}, React.ReactNode>;
  93. resetOnError?: boolean;
  94. /**
  95. * The message to display when saveOnBlur is false
  96. */
  97. saveMessage?:
  98. | React.ReactNode
  99. | ((props: PassthroughProps & {value: FieldValue}) => React.ReactNode);
  100. /**
  101. * The alert type to use when saveOnBlur is false
  102. */
  103. saveMessageAlertType?: React.ComponentProps<typeof Alert>['type'];
  104. /**
  105. * When the field is blurred should it automatically persist its value into
  106. * the model. Will show a confirm button 'save' otherwise.
  107. */
  108. saveOnBlur?: boolean;
  109. /**
  110. * A function producing an optional component with extra information.
  111. */
  112. selectionInfoFunction?: (
  113. props: PassthroughProps & {value: FieldValue; error?: string}
  114. ) => React.ReactNode;
  115. /**
  116. * Used in the form model to transform the value
  117. */
  118. setValue?: (value: FieldValue, props?: any) => any;
  119. /**
  120. * Extra styles to apply to the field
  121. */
  122. style?: React.CSSProperties;
  123. /**
  124. * Transform input when a value is set to the model.
  125. */
  126. transformInput?: (value: any) => any;
  127. // used in prettyFormString
  128. validate?: Function;
  129. }
  130. export interface FormFieldProps
  131. extends BaseProps,
  132. ObservableProps,
  133. Omit<FieldGroupProps, keyof ResolvedObservableProps | 'children'> {}
  134. /**
  135. * ResolvedProps do NOT include props which may be given functions that are
  136. * reacted on. Resolved props are used inside of makeField.
  137. */
  138. type ResolvedProps = BaseProps & FieldGroupProps;
  139. type PassthroughProps = Omit<
  140. ResolvedProps,
  141. | 'className'
  142. | 'name'
  143. | 'hideErrorMessage'
  144. | 'flexibleControlStateSize'
  145. | 'saveOnBlur'
  146. | 'saveMessage'
  147. | 'saveMessageAlertType'
  148. | 'selectionInfoFunction'
  149. | 'hideControlState'
  150. | 'defaultValue'
  151. >;
  152. class FormField extends Component<FormFieldProps> {
  153. static defaultProps = {
  154. hideErrorMessage: false,
  155. flexibleControlStateSize: false,
  156. };
  157. componentDidMount() {
  158. // Tell model about this field's props
  159. this.getModel().setFieldDescriptor(this.props.name, this.props);
  160. }
  161. componentWillUnmount() {
  162. this.getModel().removeField(this.props.name);
  163. }
  164. static contextType = FormContext;
  165. getError() {
  166. return this.getModel().getError(this.props.name);
  167. }
  168. getId() {
  169. return sanitizeQuerySelector(this.props.name);
  170. }
  171. getModel(): FormModel {
  172. return this.context.form !== undefined
  173. ? this.context.form
  174. : new MockModel(this.props);
  175. }
  176. input: HTMLElement | null = null;
  177. /**
  178. * Attempts to autofocus input field if field's name is in url hash.
  179. *
  180. * The ref must be forwarded for this to work.
  181. */
  182. handleInputMount = (node: HTMLElement | null) => {
  183. if (node && !this.input) {
  184. // TODO(mark) Clean this up. FormContext could include the location
  185. const hash = window.location?.hash;
  186. if (!hash) {
  187. return;
  188. }
  189. if (hash !== `#${this.props.name}`) {
  190. return;
  191. }
  192. // Not all form fields have this (e.g. Select fields)
  193. if (typeof node.focus === 'function') {
  194. node.focus();
  195. }
  196. }
  197. this.input = node;
  198. };
  199. /**
  200. * Update field value in form model
  201. */
  202. handleChange = (...args) => {
  203. const {name, onChange} = this.props;
  204. const {value, event} = getValueFromEvent(...args);
  205. const model = this.getModel();
  206. if (onChange) {
  207. onChange(value, event);
  208. }
  209. model.setValue(name, value);
  210. };
  211. /**
  212. * Notify model of a field being blurred
  213. */
  214. handleBlur = (...args) => {
  215. const {name, onBlur} = this.props;
  216. const {value, event} = getValueFromEvent(...args);
  217. const model = this.getModel();
  218. if (onBlur) {
  219. onBlur(value, event);
  220. }
  221. // Always call this, so model can decide what to do
  222. model.handleBlurField(name, value);
  223. };
  224. /**
  225. * Handle keydown to trigger a save on Enter
  226. */
  227. handleKeyDown = (...args) => {
  228. const {onKeyDown, name} = this.props;
  229. const {value, event} = getValueFromEvent(...args);
  230. const model = this.getModel();
  231. if (event.key === 'Enter') {
  232. model.handleBlurField(name, value);
  233. }
  234. if (onKeyDown) {
  235. onKeyDown(value, event);
  236. }
  237. };
  238. /**
  239. * Handle saving an individual field via UI button
  240. */
  241. handleSaveField = () => {
  242. const {name} = this.props;
  243. const model = this.getModel();
  244. model.handleSaveField(name, model.getValue(name));
  245. };
  246. handleCancelField = () => {
  247. const {name} = this.props;
  248. const model = this.getModel();
  249. model.handleCancelSaveField(name);
  250. };
  251. render() {
  252. const {
  253. className,
  254. name,
  255. hideErrorMessage,
  256. flexibleControlStateSize,
  257. saveOnBlur,
  258. saveMessage,
  259. saveMessageAlertType,
  260. selectionInfoFunction,
  261. hideControlState,
  262. // Don't pass `defaultValue` down to input fields, will be handled in
  263. // form model
  264. defaultValue: _defaultValue,
  265. ...otherProps
  266. } = this.props;
  267. const id = this.getId();
  268. const model = this.getModel();
  269. const saveOnBlurFieldOverride = typeof saveOnBlur !== 'undefined' && !saveOnBlur;
  270. const makeField = (resolvedObservedProps?: ResolvedObservableProps) => {
  271. const props = {...otherProps, ...resolvedObservedProps} as PassthroughProps;
  272. return (
  273. <Fragment>
  274. <Field
  275. id={id}
  276. className={className}
  277. flexibleControlStateSize={flexibleControlStateSize}
  278. {...props}
  279. >
  280. {({alignRight, inline, disabled, disabledReason}) => (
  281. <FieldControl
  282. disabled={disabled}
  283. disabledReason={disabledReason}
  284. inline={inline}
  285. alignRight={alignRight}
  286. flexibleControlStateSize={flexibleControlStateSize}
  287. hideControlState={hideControlState}
  288. controlState={<FormFieldControlState model={model} name={name} />}
  289. errorState={
  290. <Observer>
  291. {() => {
  292. const error = this.getError();
  293. const shouldShowErrorMessage = error && !hideErrorMessage;
  294. if (!shouldShowErrorMessage) {
  295. return null;
  296. }
  297. return <FieldErrorReason>{error}</FieldErrorReason>;
  298. }}
  299. </Observer>
  300. }
  301. >
  302. <Observer>
  303. {() => {
  304. const error = this.getError();
  305. const value = model.getValue(name);
  306. return (
  307. <Fragment>
  308. {this.props.children({
  309. ref: this.handleInputMount,
  310. ...props,
  311. model,
  312. name,
  313. id,
  314. onKeyDown: this.handleKeyDown,
  315. onChange: this.handleChange,
  316. onBlur: this.handleBlur,
  317. // Fixes react warnings about input switching from controlled to uncontrolled
  318. // So force to empty string for null values
  319. value: value === null ? '' : value,
  320. error,
  321. disabled,
  322. initialData: model.initialData,
  323. 'aria-describedby': `${id}_help`,
  324. })}
  325. </Fragment>
  326. );
  327. }}
  328. </Observer>
  329. </FieldControl>
  330. )}
  331. </Field>
  332. {selectionInfoFunction && (
  333. <Observer>
  334. {() => {
  335. const error = this.getError();
  336. const value = model.getValue(name);
  337. const isVisible =
  338. typeof props.visible === 'function'
  339. ? props.visible({...this.props, ...props} as ResolvedProps)
  340. : true;
  341. return (
  342. <Fragment>
  343. {isVisible ? selectionInfoFunction({...props, error, value}) : null}
  344. </Fragment>
  345. );
  346. }}
  347. </Observer>
  348. )}
  349. {saveOnBlurFieldOverride && (
  350. <Observer>
  351. {() => {
  352. const showFieldSave = model.getFieldState(name, 'showSave');
  353. const value = model.getValue(name);
  354. if (!showFieldSave) {
  355. return null;
  356. }
  357. return (
  358. <PanelAlert
  359. type={saveMessageAlertType}
  360. trailingItems={
  361. <Fragment>
  362. <Button onClick={this.handleCancelField} size="xs" type="button">
  363. {t('Cancel')}
  364. </Button>
  365. <Button
  366. priority="primary"
  367. size="xs"
  368. type="button"
  369. onClick={this.handleSaveField}
  370. >
  371. {t('Save')}
  372. </Button>
  373. </Fragment>
  374. }
  375. >
  376. {typeof saveMessage === 'function'
  377. ? saveMessage({...props, value})
  378. : saveMessage}
  379. </PanelAlert>
  380. );
  381. }}
  382. </Observer>
  383. )}
  384. </Fragment>
  385. );
  386. };
  387. const observedProps = propsToObserve
  388. .filter(p => typeof this.props[p] === 'function')
  389. .map<ObservedPropResolver>(p => [
  390. p,
  391. () => (this.props[p] as ObservedFn<{}, any>)({...this.props, model}),
  392. ]);
  393. // This field has no properties that require observation to compute their
  394. // value, this field is static and will not be re-rendered.
  395. if (observedProps.length === 0) {
  396. return makeField();
  397. }
  398. const resolveObservedProps = (
  399. props: ResolvedObservableProps,
  400. [propName, resolve]: ObservedPropResolver
  401. ) => ({
  402. ...props,
  403. [propName]: resolve(),
  404. });
  405. return (
  406. <Observer>
  407. {() => makeField(observedProps.reduce(resolveObservedProps, {}))}
  408. </Observer>
  409. );
  410. }
  411. }
  412. export default FormField;