index.tsx 14 KB

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