index.tsx 14 KB

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