model.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767
  1. import isEqual from 'lodash/isEqual';
  2. import {action, computed, makeObservable, observable, ObservableMap} from 'mobx';
  3. import {addErrorMessage, saveOnBlurUndoMessage} from 'sentry/actionCreators/indicator';
  4. import {APIRequestMethod, Client} from 'sentry/api';
  5. import FormState from 'sentry/components/forms/state';
  6. import {t} from 'sentry/locale';
  7. import {defined} from 'sentry/utils';
  8. type Snapshot = Map<string, FieldValue>;
  9. type SaveSnapshot = (() => number) | null;
  10. export type FieldValue = string | number | boolean | undefined; // is undefined valid here?
  11. export type FormOptions = {
  12. allowUndo?: boolean;
  13. apiEndpoint?: string;
  14. apiMethod?: APIRequestMethod;
  15. onFieldChange?: (id: string, finalValue: FieldValue) => void;
  16. onSubmitError?: (error: any, instance: FormModel, id?: string) => void;
  17. onSubmitSuccess?: (
  18. response: any,
  19. instance: FormModel,
  20. id?: string,
  21. change?: {new: FieldValue; old: FieldValue}
  22. ) => void;
  23. resetOnError?: boolean;
  24. saveOnBlur?: boolean;
  25. };
  26. type ClientOptions = ConstructorParameters<typeof Client>[0];
  27. type OptionsWithInitial = FormOptions & {
  28. apiOptions?: ClientOptions;
  29. initialData?: Record<string, FieldValue>;
  30. };
  31. class FormModel {
  32. /**
  33. * Map of field name -> value
  34. */
  35. fields: ObservableMap<string, FieldValue> = observable.map();
  36. /**
  37. * Errors for individual fields
  38. * Note we don't keep error in `this.fieldState` so that we can easily
  39. * See if the form is in an "error" state with the `isError` getter
  40. */
  41. @observable errors = new Map();
  42. /**
  43. * State of individual fields
  44. *
  45. * Map of field name -> object
  46. */
  47. @observable fieldState = new Map();
  48. /**
  49. * State of the form as a whole
  50. */
  51. @observable formState: FormState | undefined;
  52. /**
  53. * Holds field properties as declared in <Form>
  54. * Does not need to be observable since these props should never change
  55. */
  56. fieldDescriptor = new Map();
  57. /**
  58. * Holds a list of `fields` states
  59. */
  60. snapshots: Array<Snapshot> = [];
  61. /**
  62. * POJO of field name -> value
  63. * It holds field values "since last save"
  64. */
  65. initialData: Record<string, FieldValue> = {};
  66. api: Client;
  67. formErrors: any;
  68. options: FormOptions;
  69. constructor({initialData, apiOptions, ...options}: OptionsWithInitial = {}) {
  70. makeObservable(this);
  71. this.options = options ?? {};
  72. if (initialData) {
  73. this.setInitialData(initialData);
  74. }
  75. this.api = new Client(apiOptions);
  76. }
  77. /**
  78. * Reset state of model
  79. */
  80. reset() {
  81. this.api.clear();
  82. this.fieldDescriptor.clear();
  83. this.resetForm();
  84. }
  85. @action
  86. resetForm() {
  87. this.fields.clear();
  88. this.errors.clear();
  89. this.fieldState.clear();
  90. this.snapshots = [];
  91. this.initialData = {};
  92. }
  93. /**
  94. * Deep equality comparison between last saved state and current fields state
  95. */
  96. @computed
  97. get formChanged() {
  98. return !isEqual(this.initialData, Object.fromEntries(this.fields.toJSON()));
  99. }
  100. @computed
  101. get formData() {
  102. return this.fields;
  103. }
  104. /** Is form saving */
  105. @computed
  106. get isSaving() {
  107. return this.formState === FormState.SAVING;
  108. }
  109. /** Does form have any errors */
  110. @computed
  111. get isError() {
  112. return !!this.errors.size;
  113. }
  114. /**
  115. * Sets initial form data
  116. *
  117. * Also resets snapshots
  118. */
  119. setInitialData(initialData?: Record<string, FieldValue>) {
  120. this.fields.replace(initialData || {});
  121. this.initialData = Object.fromEntries(this.fields.toJSON()) || {};
  122. this.snapshots = [new Map(this.fields.entries())];
  123. }
  124. /**
  125. * Set form options
  126. */
  127. setFormOptions(options: FormOptions) {
  128. this.options = options || {};
  129. }
  130. /**
  131. * Set field properties
  132. */
  133. @action
  134. setFieldDescriptor(id: string, props) {
  135. // TODO(TS): add type to props
  136. this.fieldDescriptor.set(id, props);
  137. // Set default value iff initialData for field is undefined
  138. // This must take place before checking for `props.setValue` so that it can
  139. // be applied to `defaultValue`
  140. if (
  141. typeof props.defaultValue !== 'undefined' &&
  142. typeof this.initialData[id] === 'undefined'
  143. ) {
  144. this.initialData[id] =
  145. typeof props.defaultValue === 'function'
  146. ? props.defaultValue()
  147. : props.defaultValue;
  148. this.fields.set(id, this.initialData[id]);
  149. }
  150. if (typeof props.setValue === 'function') {
  151. this.initialData[id] = props.setValue(this.initialData[id], props);
  152. this.fields.set(id, this.initialData[id]);
  153. }
  154. }
  155. /**
  156. * Remove a field from the descriptor map and errors.
  157. */
  158. @action
  159. removeField(id: string) {
  160. this.fieldDescriptor.delete(id);
  161. this.errors.delete(id);
  162. }
  163. /**
  164. * Creates a cloned Map of `this.fields` and returns a closure that when called
  165. * will save Map to `snapshots
  166. */
  167. createSnapshot() {
  168. const snapshot = new Map(this.fields.entries());
  169. return () => this.snapshots.unshift(snapshot);
  170. }
  171. getDescriptor(id: string, key: string) {
  172. // Needs to call `has` or else component will not be reactive if `id` doesn't exist in observable map
  173. const descriptor = this.fieldDescriptor.has(id) && this.fieldDescriptor.get(id);
  174. if (!descriptor) {
  175. return null;
  176. }
  177. return descriptor[key];
  178. }
  179. getFieldState(id: string, key: string) {
  180. // Needs to call `has` or else component will not be reactive if `id` doesn't exist in observable map
  181. const fieldState = this.fieldState.has(id) && this.fieldState.get(id);
  182. if (!fieldState) {
  183. return null;
  184. }
  185. return fieldState[key];
  186. }
  187. getValue(id: string) {
  188. return this.fields.has(id) ? this.fields.get(id) : '';
  189. }
  190. getTransformedValue(id: string) {
  191. const fieldDescriptor = this.fieldDescriptor.get(id);
  192. const transformer =
  193. fieldDescriptor && typeof fieldDescriptor.getValue === 'function'
  194. ? fieldDescriptor.getValue
  195. : null;
  196. const value = this.getValue(id);
  197. return transformer ? transformer(value) : value;
  198. }
  199. /**
  200. * Data represented in UI
  201. */
  202. getData() {
  203. return Object.fromEntries(this.fields.toJSON());
  204. }
  205. /**
  206. * Form data that will be sent to API endpoint (i.e. after transforms)
  207. */
  208. getTransformedData() {
  209. const form = this.getData();
  210. return Object.keys(form)
  211. .map(id => [id, this.getTransformedValue(id)])
  212. .reduce((acc, [id, value]) => {
  213. acc[id] = value;
  214. return acc;
  215. }, {});
  216. }
  217. getError(id: string) {
  218. return this.errors.has(id) && this.errors.get(id);
  219. }
  220. // Returns true if not required or is required and is not empty
  221. isValidRequiredField(id: string) {
  222. // Check field descriptor to see if field is required
  223. const isRequired = this.getDescriptor(id, 'required');
  224. const value = this.getValue(id);
  225. return !isRequired || (value !== '' && defined(value));
  226. }
  227. isValidField(id: string) {
  228. return (this.getError(id) || []).length === 0;
  229. }
  230. doApiRequest({
  231. apiEndpoint,
  232. apiMethod,
  233. data,
  234. }: {
  235. data: object;
  236. apiEndpoint?: string;
  237. apiMethod?: APIRequestMethod;
  238. }) {
  239. const endpoint = apiEndpoint || this.options.apiEndpoint || '';
  240. const method = apiMethod || this.options.apiMethod;
  241. return new Promise((resolve, reject) =>
  242. this.api.request(endpoint, {
  243. method,
  244. data,
  245. success: response => resolve(response),
  246. error: error => reject(error),
  247. })
  248. );
  249. }
  250. /**
  251. * Set the value of the form field
  252. * if quiet is true, we skip callbacks, validations
  253. */
  254. @action
  255. setValue(id: string, value: FieldValue, {quiet}: {quiet?: boolean} = {}) {
  256. const fieldDescriptor = this.fieldDescriptor.get(id);
  257. let finalValue = value;
  258. if (fieldDescriptor && typeof fieldDescriptor.transformInput === 'function') {
  259. finalValue = fieldDescriptor.transformInput(value);
  260. }
  261. this.fields.set(id, finalValue);
  262. if (quiet) {
  263. return;
  264. }
  265. if (this.options.onFieldChange) {
  266. this.options.onFieldChange(id, finalValue);
  267. }
  268. this.validateField(id);
  269. this.updateShowSaveState(id, finalValue);
  270. this.updateShowReturnButtonState(id, finalValue);
  271. }
  272. @action
  273. validateField(id: string) {
  274. const validate = this.getDescriptor(id, 'validate');
  275. let errors: any[] = [];
  276. if (typeof validate === 'function') {
  277. // Returns "tuples" of [id, error string]
  278. errors = validate({model: this, id, form: this.getData()}) || [];
  279. }
  280. const fieldIsRequiredMessage = t('Field is required');
  281. if (!this.isValidRequiredField(id)) {
  282. errors.push([id, fieldIsRequiredMessage]);
  283. }
  284. // If we have no errors, ensure we clear the field
  285. errors = errors.length === 0 ? [[id, null]] : errors;
  286. errors.forEach(([field, errorMessage]) => this.setError(field, errorMessage));
  287. return undefined;
  288. }
  289. @action
  290. updateShowSaveState(id: string, value: FieldValue) {
  291. const isValueChanged = value !== this.initialData[id];
  292. // Update field state to "show save" if save on blur is disabled for this field
  293. // (only if contents of field differs from initial value)
  294. const saveOnBlurFieldOverride = this.getDescriptor(id, 'saveOnBlur');
  295. if (typeof saveOnBlurFieldOverride === 'undefined' || saveOnBlurFieldOverride) {
  296. return;
  297. }
  298. if (this.getFieldState(id, 'showSave') === isValueChanged) {
  299. return;
  300. }
  301. this.setFieldState(id, 'showSave', isValueChanged);
  302. }
  303. @action
  304. updateShowReturnButtonState(id: string, value: FieldValue) {
  305. const isValueChanged = value !== this.initialData[id];
  306. const shouldShowReturnButton = this.getDescriptor(id, 'showReturnButton');
  307. if (!shouldShowReturnButton) {
  308. return;
  309. }
  310. // Only update state if state has changed
  311. if (this.getFieldState(id, 'showReturnButton') === isValueChanged) {
  312. return;
  313. }
  314. this.setFieldState(id, 'showReturnButton', isValueChanged);
  315. }
  316. /**
  317. * Changes form values to previous saved state
  318. */
  319. @action
  320. undo() {
  321. // Always have initial data snapshot
  322. if (this.snapshots.length < 2) {
  323. return null;
  324. }
  325. this.snapshots.shift();
  326. this.fields.replace(this.snapshots[0]);
  327. return true;
  328. }
  329. /**
  330. * Attempts to save entire form to server and saves a snapshot for undos
  331. */
  332. @action
  333. saveForm() {
  334. if (!this.validateForm()) {
  335. return null;
  336. }
  337. let saveSnapshot: SaveSnapshot = this.createSnapshot();
  338. const request = this.doApiRequest({
  339. data: this.getTransformedData(),
  340. });
  341. this.setFormSaving();
  342. request
  343. .then(resp => {
  344. // save snapshot
  345. if (saveSnapshot) {
  346. saveSnapshot();
  347. saveSnapshot = null;
  348. }
  349. if (this.options.onSubmitSuccess) {
  350. this.options.onSubmitSuccess(resp, this);
  351. }
  352. })
  353. .catch(resp => {
  354. // should we revert field value to last known state?
  355. saveSnapshot = null;
  356. if (this.options.resetOnError) {
  357. this.setInitialData({});
  358. }
  359. this.submitError(resp);
  360. if (this.options.onSubmitError) {
  361. this.options.onSubmitError(resp, this);
  362. }
  363. });
  364. return request;
  365. }
  366. /**
  367. * Attempts to save field and show undo message if necessary.
  368. * Calls submit handlers.
  369. * TODO(billy): This should return a promise that resolves (instead of null)
  370. */
  371. @action
  372. saveField(id: string, currentValue: FieldValue) {
  373. const oldValue = this.initialData[id];
  374. const savePromise = this.saveFieldRequest(id, currentValue);
  375. if (!savePromise) {
  376. return null;
  377. }
  378. return savePromise
  379. .then(resp => {
  380. const newValue = this.getValue(id);
  381. const change = {old: oldValue, new: newValue};
  382. // Only use `allowUndo` option if explicitly defined
  383. if (typeof this.options.allowUndo === 'undefined' || this.options.allowUndo) {
  384. saveOnBlurUndoMessage(change, this, id);
  385. }
  386. if (this.options.onSubmitSuccess) {
  387. this.options.onSubmitSuccess(resp, this, id, change);
  388. }
  389. return resp;
  390. })
  391. .catch(error => {
  392. if (this.options.onSubmitError) {
  393. this.options.onSubmitError(error, this, id);
  394. }
  395. return {};
  396. });
  397. }
  398. /**
  399. * Saves a field with new value
  400. *
  401. * If field has changes, field does not have errors, then it will:
  402. * Save a snapshot, apply any data transforms, perform api request.
  403. *
  404. * If successful then: 1) reset save state, 2) update `initialData`, 3) save snapshot
  405. * If failed then: 1) reset save state, 2) add error state
  406. */
  407. @action
  408. saveFieldRequest(id: string, currentValue: FieldValue) {
  409. const initialValue = this.initialData[id];
  410. // Don't save if field hasn't changed
  411. // Don't need to check for error state since initialData wouldn't have updated since last error
  412. if (
  413. currentValue === initialValue ||
  414. (currentValue === '' && !defined(initialValue))
  415. ) {
  416. return null;
  417. }
  418. // Check for error first
  419. this.validateField(id);
  420. if (!this.isValidField(id)) {
  421. return null;
  422. }
  423. // shallow clone fields
  424. let saveSnapshot: SaveSnapshot = this.createSnapshot();
  425. // Save field + value
  426. this.setSaving(id, true);
  427. const fieldDescriptor = this.fieldDescriptor.get(id);
  428. // Check if field needs to handle transforming request object
  429. const getData =
  430. typeof fieldDescriptor.getData === 'function' ? fieldDescriptor.getData : a => a;
  431. const request = this.doApiRequest({
  432. data: getData(
  433. {[id]: this.getTransformedValue(id)},
  434. {model: this, id, form: this.getData()}
  435. ),
  436. });
  437. request
  438. .then(data => {
  439. this.setSaving(id, false);
  440. // save snapshot
  441. if (saveSnapshot) {
  442. saveSnapshot();
  443. saveSnapshot = null;
  444. }
  445. // Update initialData after successfully saving a field as it will now be the baseline value
  446. this.initialData[id] = this.getValue(id);
  447. return data;
  448. })
  449. .catch(resp => {
  450. // should we revert field value to last known state?
  451. saveSnapshot = null;
  452. // Field can be configured to reset on error
  453. // e.g. BooleanFields
  454. const shouldReset = this.getDescriptor(id, 'resetOnError');
  455. if (shouldReset) {
  456. this.setValue(id, initialValue);
  457. }
  458. // API can return a JSON object with either:
  459. // 1) map of {[fieldName] => Array<ErrorMessages>}
  460. // 2) {'non_field_errors' => Array<ErrorMessages>}
  461. if (resp && resp.responseJSON) {
  462. // non-field errors can be camelcase or snake case
  463. const nonFieldErrors =
  464. resp.responseJSON.non_field_errors || resp.responseJSON.nonFieldErrors;
  465. // Show resp msg from API endpoint if possible
  466. if (Array.isArray(resp.responseJSON[id]) && resp.responseJSON[id].length) {
  467. // Just take first resp for now
  468. this.setError(id, resp.responseJSON[id][0]);
  469. } else if (Array.isArray(nonFieldErrors) && nonFieldErrors.length) {
  470. addErrorMessage(nonFieldErrors[0], {duration: 10000});
  471. // Reset saving state
  472. this.setError(id, '');
  473. } else {
  474. this.setError(id, 'Failed to save');
  475. }
  476. } else {
  477. // Default error behavior
  478. this.setError(id, 'Failed to save');
  479. }
  480. // eslint-disable-next-line no-console
  481. console.error('Error saving form field', resp && resp.responseJSON);
  482. });
  483. return request;
  484. }
  485. /**
  486. * This is called when a field is blurred
  487. *
  488. * If `saveOnBlur` is set then call `saveField` and handle form callbacks accordingly
  489. */
  490. @action
  491. handleBlurField(id: string, currentValue: FieldValue) {
  492. // Nothing to do if `saveOnBlur` is not on
  493. if (!this.options.saveOnBlur) {
  494. return null;
  495. }
  496. // Fields can individually set `saveOnBlur` to `false` (note this is ignored when `undefined`)
  497. const saveOnBlurFieldOverride = this.getDescriptor(id, 'saveOnBlur');
  498. if (typeof saveOnBlurFieldOverride !== 'undefined' && !saveOnBlurFieldOverride) {
  499. return null;
  500. }
  501. return this.saveField(id, currentValue);
  502. }
  503. @action
  504. setFormSaving() {
  505. this.formState = FormState.SAVING;
  506. }
  507. /**
  508. * This is called when a field does not saveOnBlur and has an individual "Save" button
  509. */
  510. @action
  511. handleSaveField(id: string, currentValue: FieldValue) {
  512. const savePromise = this.saveField(id, currentValue);
  513. if (!savePromise) {
  514. return null;
  515. }
  516. return savePromise.then(() => {
  517. this.setFieldState(id, 'showSave', false);
  518. });
  519. }
  520. /**
  521. * Cancel "Save Field" state and revert form value back to initial value
  522. */
  523. @action
  524. handleCancelSaveField(id: string) {
  525. this.setValue(id, this.initialData[id]);
  526. this.setFieldState(id, 'showSave', false);
  527. }
  528. @action
  529. setFieldState(id: string, key: string, value: FieldValue) {
  530. const state = {
  531. ...(this.fieldState.get(id) || {}),
  532. [key]: value,
  533. };
  534. this.fieldState.set(id, state);
  535. }
  536. /**
  537. * Set "saving" state for field
  538. */
  539. @action
  540. setSaving(id: string, value: FieldValue) {
  541. // When saving, reset error state
  542. this.setError(id, false);
  543. this.setFieldState(id, FormState.SAVING, value);
  544. this.setFieldState(id, FormState.READY, !value);
  545. }
  546. /**
  547. * Set "error" state for field
  548. */
  549. @action
  550. setError(id: string, error: boolean | string) {
  551. // Note we don't keep error in `this.fieldState` so that we can easily
  552. // See if the form is in an "error" state with the `isError` getter
  553. if (!!error) {
  554. this.formState = FormState.ERROR;
  555. this.errors.set(id, error);
  556. } else {
  557. this.formState = FormState.READY;
  558. this.errors.delete(id);
  559. }
  560. // Field should no longer to "saving", but is not necessarily "ready"
  561. this.setFieldState(id, FormState.SAVING, false);
  562. }
  563. /**
  564. * Returns true if there are no errors
  565. */
  566. @action
  567. validateForm(): boolean {
  568. Array.from(this.fieldDescriptor.keys()).forEach(id => !this.validateField(id));
  569. return !this.isError;
  570. }
  571. @action
  572. handleErrorResponse({responseJSON: resp}: {responseJSON?: any} = {}) {
  573. if (!resp) {
  574. return;
  575. }
  576. // Show resp msg from API endpoint if possible
  577. Object.keys(resp).forEach(id => {
  578. // non-field errors can be camelcase or snake case
  579. const nonFieldErrors = resp.non_field_errors || resp.nonFieldErrors;
  580. if (
  581. (id === 'non_field_errors' || id === 'nonFieldErrors') &&
  582. Array.isArray(nonFieldErrors) &&
  583. nonFieldErrors.length
  584. ) {
  585. addErrorMessage(nonFieldErrors[0], {duration: 10000});
  586. } else if (Array.isArray(resp[id]) && resp[id].length) {
  587. // Just take first resp for now
  588. this.setError(id, resp[id][0]);
  589. }
  590. });
  591. }
  592. @action
  593. submitSuccess(data: Record<string, FieldValue>) {
  594. // update initial data
  595. this.formState = FormState.READY;
  596. this.initialData = data;
  597. }
  598. @action
  599. submitError(err: {responseJSON?: any}) {
  600. this.formState = FormState.ERROR;
  601. this.formErrors = this.mapFormErrors(err.responseJSON);
  602. this.handleErrorResponse({responseJSON: this.formErrors});
  603. }
  604. mapFormErrors(responseJSON?: any) {
  605. return responseJSON;
  606. }
  607. }
  608. /**
  609. * The mock model mocks the model interface to simply return values from the props
  610. *
  611. * This is valuable for using form fields outside of a Form context. Disables a
  612. * lot of functionality however.
  613. */
  614. export class MockModel {
  615. // TODO(TS)
  616. props: any;
  617. initialData: Record<string, FieldValue>;
  618. constructor(props) {
  619. this.props = props;
  620. this.initialData = {
  621. [props.name]: props.value,
  622. };
  623. }
  624. setValue() {}
  625. setFieldDescriptor() {}
  626. removeField() {}
  627. handleBlurField() {}
  628. getValue() {
  629. return this.props.value;
  630. }
  631. getError() {
  632. return this.props.error;
  633. }
  634. getFieldState() {
  635. return false;
  636. }
  637. }
  638. export default FormModel;