import find from 'lodash/find'; import isEqual from 'lodash/isEqual'; import {action, computed, makeObservable, observable, ObservableMap} from 'mobx'; import {addErrorMessage, saveOnBlurUndoMessage} from 'sentry/actionCreators/indicator'; import {APIRequestMethod, Client} from 'sentry/api'; import FormState from 'sentry/components/forms/state'; import {t} from 'sentry/locale'; import type {Choice} from 'sentry/types'; import {defined} from 'sentry/utils'; type Snapshot = Map; type SaveSnapshot = (() => number) | null; export type FieldValue = string | number | boolean | Choice | undefined; // is undefined valid here? export type FormOptions = { allowUndo?: boolean; apiEndpoint?: string; apiMethod?: APIRequestMethod; onFieldChange?: (id: string, finalValue: FieldValue) => void; onSubmitError?: (error: any, instance: FormModel, id?: string) => void; onSubmitSuccess?: ( response: any, instance: FormModel, id?: string, change?: {new: FieldValue; old: FieldValue} ) => void; resetOnError?: boolean; saveOnBlur?: boolean; }; type ClientOptions = ConstructorParameters[0]; type OptionsWithInitial = FormOptions & { apiOptions?: ClientOptions; initialData?: Record; }; class FormModel { /** * Map of field name -> value */ fields: ObservableMap = observable.map(); /** * Errors for individual fields * Note we don't keep error in `this.fieldState` so that we can easily * See if the form is in an "error" state with the `isError` getter */ @observable errors = new Map(); /** * State of individual fields * * Map of field name -> object */ @observable fieldState = new Map(); /** * State of the form as a whole */ @observable formState: FormState | undefined; /** * Holds field properties as declared in
* Does not need to be observable since these props should never change */ fieldDescriptor = new Map(); /** * Holds a list of `fields` states */ snapshots: Array = []; /** * POJO of field name -> value * It holds field values "since last save" */ initialData: Record = {}; api: Client; formErrors: any; options: FormOptions; constructor({initialData, apiOptions, ...options}: OptionsWithInitial = {}) { makeObservable(this); this.options = options ?? {}; if (initialData) { this.setInitialData(initialData); } this.api = new Client(apiOptions); } /** * Reset state of model */ reset() { this.api.clear(); this.fieldDescriptor.clear(); this.resetForm(); } @action resetForm() { this.fields.clear(); this.errors.clear(); this.fieldState.clear(); this.snapshots = []; this.initialData = {}; } /** * Deep equality comparison between last saved state and current fields state */ @computed get formChanged() { return !isEqual(this.initialData, Object.fromEntries(this.fields.toJSON())); } @computed get formData() { return this.fields; } /** Is form saving */ @computed get isSaving() { return this.formState === FormState.SAVING; } /** Does form have any errors */ @computed get isError() { return !!this.errors.size; } /** * Sets initial form data * * Also resets snapshots */ setInitialData(initialData?: Record) { this.fields.replace(initialData || {}); this.initialData = Object.fromEntries(this.fields.toJSON()) || {}; this.snapshots = [new Map(this.fields.entries())]; } /** * Set form options */ setFormOptions(options: FormOptions) { this.options = options || {}; } /** * Set field properties */ @action setFieldDescriptor(id: string, props) { // TODO(TS): add type to props this.fieldDescriptor.set(id, props); // Set default value iff initialData for field is undefined // This must take place before checking for `props.setValue` so that it can // be applied to `defaultValue` if ( typeof props.defaultValue !== 'undefined' && typeof this.initialData[id] === 'undefined' ) { this.initialData[id] = typeof props.defaultValue === 'function' ? props.defaultValue() : props.defaultValue; this.fields.set(id, this.initialData[id]); } if (typeof props.setValue === 'function') { this.initialData[id] = props.setValue(this.initialData[id], props); this.fields.set(id, this.initialData[id]); } } /** * Remove a field from the descriptor map and errors. */ @action removeField(id: string) { this.fieldDescriptor.delete(id); this.errors.delete(id); } /** * Creates a cloned Map of `this.fields` and returns a closure that when called * will save Map to `snapshots */ createSnapshot() { const snapshot = new Map(this.fields.entries()); return () => this.snapshots.unshift(snapshot); } getDescriptor(id: string, key: string) { // Needs to call `has` or else component will not be reactive if `id` doesn't exist in observable map const descriptor = this.fieldDescriptor.has(id) && this.fieldDescriptor.get(id); if (!descriptor) { return null; } return descriptor[key]; } getFieldState(id: string, key: string) { // Needs to call `has` or else component will not be reactive if `id` doesn't exist in observable map const fieldState = this.fieldState.has(id) && this.fieldState.get(id); if (!fieldState) { return null; } return fieldState[key]; } getValue(id: string) { return this.fields.has(id) ? this.fields.get(id) : ''; } getTransformedValue(id: string) { const fieldDescriptor = this.fieldDescriptor.get(id); const transformer = fieldDescriptor && typeof fieldDescriptor.getValue === 'function' ? fieldDescriptor.getValue : null; const value = this.getValue(id); return transformer ? transformer(value) : value; } /** * Data represented in UI */ getData() { return Object.fromEntries(this.fields.toJSON()); } /** * Form data that will be sent to API endpoint (i.e. after transforms) */ getTransformedData() { const form = this.getData(); return Object.keys(form) .map(id => [id, this.getTransformedValue(id)]) .reduce((acc, [id, value]) => { acc[id] = value; return acc; }, {}); } getError(id: string) { return this.errors.has(id) && this.errors.get(id); } // Returns true if not required or is required and is not empty isValidRequiredField(id: string) { // Check field descriptor to see if field is required const isRequired = this.getDescriptor(id, 'required'); const value = this.getValue(id); return !isRequired || (value !== '' && defined(value)); } isValidField(id: string) { return (this.getError(id) || []).length === 0; } doApiRequest({ apiEndpoint, apiMethod, data, }: { data: object; apiEndpoint?: string; apiMethod?: APIRequestMethod; }) { const endpoint = apiEndpoint || this.options.apiEndpoint || ''; const method = apiMethod || this.options.apiMethod; return new Promise((resolve, reject) => this.api.request(endpoint, { method, data, success: response => resolve(response), error: error => reject(error), }) ); } /** * Set the value of the form field * if quiet is true, we skip callbacks, validations */ @action setValue(id: string, value: FieldValue, {quiet}: {quiet?: boolean} = {}) { const fieldDescriptor = this.fieldDescriptor.get(id); let finalValue = value; if (fieldDescriptor && typeof fieldDescriptor.transformInput === 'function') { finalValue = fieldDescriptor.transformInput(value); } this.fields.set(id, finalValue); if (quiet) { return; } if (this.options.onFieldChange) { this.options.onFieldChange(id, finalValue); } this.validateField(id); this.updateShowSaveState(id, finalValue); this.updateShowReturnButtonState(id, finalValue); } @action validateField(id: string) { const validate = this.getDescriptor(id, 'validate'); let errors: any[] = []; if (typeof validate === 'function') { // Returns "tuples" of [id, error string] errors = validate({model: this, id, form: this.getData()}) || []; } const fieldIsRequiredMessage = t('Field is required'); if (!this.isValidRequiredField(id)) { errors.push([id, fieldIsRequiredMessage]); } // If we have no errors, ensure we clear the field errors = errors.length === 0 ? [[id, null]] : errors; errors.forEach(([field, errorMessage]) => this.setError(field, errorMessage)); return undefined; } @action updateShowSaveState(id: string, value: FieldValue) { const isValueChanged = value !== this.initialData[id]; // Update field state to "show save" if save on blur is disabled for this field // (only if contents of field differs from initial value) const saveOnBlurFieldOverride = this.getDescriptor(id, 'saveOnBlur'); if (typeof saveOnBlurFieldOverride === 'undefined' || saveOnBlurFieldOverride) { return; } if (this.getFieldState(id, 'showSave') === isValueChanged) { return; } this.setFieldState(id, 'showSave', isValueChanged); } @action updateShowReturnButtonState(id: string, value: FieldValue) { const isValueChanged = value !== this.initialData[id]; const shouldShowReturnButton = this.getDescriptor(id, 'showReturnButton'); if (!shouldShowReturnButton) { return; } // Only update state if state has changed if (this.getFieldState(id, 'showReturnButton') === isValueChanged) { return; } this.setFieldState(id, 'showReturnButton', isValueChanged); } /** * Changes form values to previous saved state */ @action undo() { // Always have initial data snapshot if (this.snapshots.length < 2) { return null; } this.snapshots.shift(); this.fields.replace(this.snapshots[0]); return true; } /** * Attempts to save entire form to server and saves a snapshot for undos */ @action saveForm() { if (!this.validateForm()) { return null; } let saveSnapshot: SaveSnapshot = this.createSnapshot(); const request = this.doApiRequest({ data: this.getTransformedData(), }); this.setFormSaving(); request .then(resp => { // save snapshot if (saveSnapshot) { saveSnapshot(); saveSnapshot = null; } if (this.options.onSubmitSuccess) { this.options.onSubmitSuccess(resp, this); } }) .catch(resp => { // should we revert field value to last known state? saveSnapshot = null; if (this.options.resetOnError) { this.setInitialData({}); } this.submitError(resp); if (this.options.onSubmitError) { this.options.onSubmitError(resp, this); } }); return request; } /** * Attempts to save field and show undo message if necessary. * Calls submit handlers. * TODO(billy): This should return a promise that resolves (instead of null) */ @action saveField(id: string, currentValue: FieldValue) { const oldValue = this.initialData[id]; const savePromise = this.saveFieldRequest(id, currentValue); if (!savePromise) { return null; } return savePromise .then(resp => { const newValue = this.getValue(id); const change = {old: oldValue, new: newValue}; // Only use `allowUndo` option if explicitly defined if (typeof this.options.allowUndo === 'undefined' || this.options.allowUndo) { saveOnBlurUndoMessage(change, this, id); } if (this.options.onSubmitSuccess) { this.options.onSubmitSuccess(resp, this, id, change); } return resp; }) .catch(error => { if (this.options.onSubmitError) { this.options.onSubmitError(error, this, id); } return {}; }); } /** * Saves a field with new value * * If field has changes, field does not have errors, then it will: * Save a snapshot, apply any data transforms, perform api request. * * If successful then: 1) reset save state, 2) update `initialData`, 3) save snapshot * If failed then: 1) reset save state, 2) add error state */ @action saveFieldRequest(id: string, currentValue: FieldValue) { const initialValue = this.initialData[id]; // Don't save if field hasn't changed // Don't need to check for error state since initialData wouldn't have updated since last error if ( currentValue === initialValue || (currentValue === '' && !defined(initialValue)) ) { return null; } // Check for error first this.validateField(id); if (!this.isValidField(id)) { return null; } // shallow clone fields let saveSnapshot: SaveSnapshot = this.createSnapshot(); // Save field + value this.setSaving(id, true); const fieldDescriptor = this.fieldDescriptor.get(id); // Check if field needs to handle transforming request object const getData = typeof fieldDescriptor.getData === 'function' ? fieldDescriptor.getData : a => a; const request = this.doApiRequest({ data: getData( {[id]: this.getTransformedValue(id)}, {model: this, id, form: this.getData()} ), }); request .then(data => { this.setSaving(id, false); // save snapshot if (saveSnapshot) { saveSnapshot(); saveSnapshot = null; } // Update initialData after successfully saving a field as it will now be the baseline value this.initialData[id] = this.getValue(id); return data; }) .catch(resp => { // should we revert field value to last known state? saveSnapshot = null; // Field can be configured to reset on error // e.g. BooleanFields const shouldReset = this.getDescriptor(id, 'resetOnError'); if (shouldReset) { this.setValue(id, initialValue); } // API can return a JSON object with either: // 1) map of {[fieldName] => Array} // 2) {'non_field_errors' => Array} if (resp && resp.responseJSON) { // non-field errors can be camelcase or snake case const nonFieldErrors = resp.responseJSON.non_field_errors || resp.responseJSON.nonFieldErrors; // Show resp msg from API endpoint if possible if (Array.isArray(resp.responseJSON[id]) && resp.responseJSON[id].length) { // Just take first resp for now this.setError(id, resp.responseJSON[id][0]); } else if (Array.isArray(nonFieldErrors) && nonFieldErrors.length) { addErrorMessage(nonFieldErrors[0], {duration: 10000}); // Reset saving state this.setError(id, ''); // find the first entry with an error } else if ( find( Object.entries(resp.responseJSON), ([_, v]) => Array.isArray(v) && v.length ) ) { this.setError( id, find( Object.entries(resp.responseJSON), ([_, v]) => Array.isArray(v) && v.length )?.[1] ); } else { this.setError(id, 'Failed to save'); } } else { // Default error behavior this.setError(id, 'Failed to save'); } // eslint-disable-next-line no-console console.error('Error saving form field', resp && resp.responseJSON); }); return request; } /** * This is called when a field is blurred * * If `saveOnBlur` is set then call `saveField` and handle form callbacks accordingly */ @action handleBlurField(id: string, currentValue: FieldValue) { // Nothing to do if `saveOnBlur` is not on if (!this.options.saveOnBlur) { return null; } // Fields can individually set `saveOnBlur` to `false` (note this is ignored when `undefined`) const saveOnBlurFieldOverride = this.getDescriptor(id, 'saveOnBlur'); if (typeof saveOnBlurFieldOverride !== 'undefined' && !saveOnBlurFieldOverride) { return null; } return this.saveField(id, currentValue); } @action setFormSaving() { this.formState = FormState.SAVING; } /** * This is called when a field does not saveOnBlur and has an individual "Save" button */ @action handleSaveField(id: string, currentValue: FieldValue) { const savePromise = this.saveField(id, currentValue); if (!savePromise) { return null; } return savePromise.then(() => { this.setFieldState(id, 'showSave', false); }); } /** * Cancel "Save Field" state and revert form value back to initial value */ @action handleCancelSaveField(id: string) { this.setValue(id, this.initialData[id]); this.setFieldState(id, 'showSave', false); } @action setFieldState(id: string, key: string, value: FieldValue) { const state = { ...(this.fieldState.get(id) || {}), [key]: value, }; this.fieldState.set(id, state); } /** * Set "saving" state for field */ @action setSaving(id: string, value: FieldValue) { // When saving, reset error state this.setError(id, false); this.setFieldState(id, FormState.SAVING, value); this.setFieldState(id, FormState.READY, !value); } /** * Set "error" state for field */ @action setError(id: string, error: boolean | string) { // Note we don't keep error in `this.fieldState` so that we can easily // See if the form is in an "error" state with the `isError` getter if (!!error) { this.formState = FormState.ERROR; this.errors.set(id, error); } else { this.formState = FormState.READY; this.errors.delete(id); } // Field should no longer to "saving", but is not necessarily "ready" this.setFieldState(id, FormState.SAVING, false); } /** * Returns true if there are no errors */ @action validateForm(): boolean { Array.from(this.fieldDescriptor.keys()).forEach(id => !this.validateField(id)); return !this.isError; } @action handleErrorResponse({responseJSON: resp}: {responseJSON?: any} = {}) { if (!resp) { return; } // Show resp msg from API endpoint if possible Object.keys(resp).forEach(id => { // non-field errors can be camelcase or snake case const nonFieldErrors = resp.non_field_errors || resp.nonFieldErrors; if ( (id === 'non_field_errors' || id === 'nonFieldErrors') && Array.isArray(nonFieldErrors) && nonFieldErrors.length ) { addErrorMessage(nonFieldErrors[0], {duration: 10000}); } else if (Array.isArray(resp[id]) && resp[id].length) { // Just take first resp for now this.setError(id, resp[id][0]); } }); } @action submitSuccess(data: Record) { // update initial data this.formState = FormState.READY; this.initialData = data; } @action submitError(err: {responseJSON?: any}) { this.formState = FormState.ERROR; this.formErrors = this.mapFormErrors(err.responseJSON); this.handleErrorResponse({responseJSON: this.formErrors}); } mapFormErrors(responseJSON?: any) { return responseJSON; } } /** * The mock model mocks the model interface to simply return values from the props * * This is valuable for using form fields outside of a Form context. Disables a * lot of functionality however. */ export class MockModel { // TODO(TS) props: any; initialData: Record; constructor(props) { this.props = props; this.initialData = { [props.name]: props.value, }; } setValue() {} setFieldDescriptor() {} removeField() {} handleBlurField() {} getValue() { return this.props.value; } getError() { return this.props.error; } getFieldState() { return false; } } export default FormModel;