indicator.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import * as React from 'react';
  2. import styled from '@emotion/styled';
  3. import * as Sentry from '@sentry/react';
  4. import IndicatorActions from 'sentry/actions/indicatorActions';
  5. import FormModel, {FieldValue} from 'sentry/components/forms/model';
  6. import {DEFAULT_TOAST_DURATION} from 'sentry/constants';
  7. import {t, tct} from 'sentry/locale';
  8. import space from 'sentry/styles/space';
  9. type IndicatorType = 'loading' | 'error' | 'success' | 'undo' | '';
  10. type Options = {
  11. append?: boolean;
  12. disableDismiss?: boolean;
  13. duration?: number;
  14. modelArg?: {
  15. id: string;
  16. model: FormModel;
  17. undo: () => void;
  18. };
  19. undo?: () => void;
  20. };
  21. export type Indicator = {
  22. id: string | number;
  23. message: React.ReactNode;
  24. options: Options;
  25. type: IndicatorType;
  26. clearId?: null | number;
  27. };
  28. // Removes a single indicator
  29. export function removeIndicator(indicator: Indicator) {
  30. IndicatorActions.remove(indicator);
  31. }
  32. // Clears all indicators
  33. export function clearIndicators() {
  34. IndicatorActions.clear();
  35. }
  36. // Note previous IndicatorStore.add behavior was to default to "loading" if no type was supplied
  37. export function addMessage(
  38. msg: React.ReactNode,
  39. type: IndicatorType,
  40. options: Options = {}
  41. ): void {
  42. const {duration: optionsDuration, append, ...rest} = options;
  43. // XXX: Debug for https://sentry.io/organizations/sentry/issues/1595204979/
  44. if (
  45. // @ts-expect-error
  46. typeof msg?.message !== 'undefined' &&
  47. // @ts-expect-error
  48. typeof msg?.code !== 'undefined' &&
  49. // @ts-expect-error
  50. typeof msg?.extra !== 'undefined'
  51. ) {
  52. Sentry.captureException(new Error('Attempt to XHR response to Indicators'));
  53. }
  54. // use default only if undefined, as 0 is a valid duration
  55. const duration =
  56. typeof optionsDuration === 'undefined' ? DEFAULT_TOAST_DURATION : optionsDuration;
  57. const action = append ? 'append' : 'replace';
  58. // XXX: This differs from `IndicatorStore.add` since it won't return the indicator that is created
  59. // because we are firing an action. You can just add a new message and it will, by default,
  60. // replace active indicator
  61. IndicatorActions[action](msg, type, {...rest, duration});
  62. }
  63. function addMessageWithType(type: IndicatorType) {
  64. return (msg: React.ReactNode, options?: Options) => addMessage(msg, type, options);
  65. }
  66. export function addLoadingMessage(
  67. msg: React.ReactNode = t('Saving changes...'),
  68. options?: Options
  69. ) {
  70. return addMessageWithType('loading')(msg, options);
  71. }
  72. export function addErrorMessage(msg: React.ReactNode, options?: Options) {
  73. return addMessageWithType('error')(msg, options);
  74. }
  75. export function addSuccessMessage(msg: React.ReactNode, options?: Options) {
  76. return addMessageWithType('success')(msg, options);
  77. }
  78. const PRETTY_VALUES: Map<unknown, string> = new Map([
  79. ['', '<empty>'],
  80. [null, '<none>'],
  81. [undefined, '<unset>'],
  82. // if we don't cast as any, then typescript complains because booleans are not valid keys
  83. [true as any, 'enabled'],
  84. [false as any, 'disabled'],
  85. ]);
  86. // Transform form values into a string
  87. // Otherwise bool values will not get rendered and empty strings look like a bug
  88. const prettyFormString = (val: ChangeValue, model: FormModel, fieldName: string) => {
  89. const descriptor = model.fieldDescriptor.get(fieldName);
  90. if (descriptor && typeof descriptor.formatMessageValue === 'function') {
  91. const initialData = model.initialData;
  92. // XXX(epurkhiser): We pass the "props" as the descriptor and initialData.
  93. // This isn't necessarily all of the props of the form field, but should
  94. // make up a good portion needed for formatting.
  95. return descriptor.formatMessageValue(val, {...descriptor, initialData});
  96. }
  97. if (PRETTY_VALUES.has(val)) {
  98. return PRETTY_VALUES.get(val);
  99. }
  100. return typeof val === 'object' ? val : String(val);
  101. };
  102. // Some fields have objects in them.
  103. // For example project key rate limits.
  104. type ChangeValue = FieldValue | Record<string, any>;
  105. type Change = {
  106. new: ChangeValue;
  107. old: ChangeValue;
  108. };
  109. /**
  110. * This will call an action creator to generate a "Toast" message that
  111. * notifies user the field that changed with its previous and current values.
  112. *
  113. * Also allows for undo
  114. */
  115. export function saveOnBlurUndoMessage(
  116. change: Change,
  117. model: FormModel,
  118. fieldName: string
  119. ) {
  120. if (!model) {
  121. return;
  122. }
  123. const label = model.getDescriptor(fieldName, 'label');
  124. if (!label) {
  125. return;
  126. }
  127. const prettifyValue = (val: ChangeValue) => prettyFormString(val, model, fieldName);
  128. // Hide the change text when formatMessageValue is explicitly set to false
  129. const showChangeText = model.getDescriptor(fieldName, 'formatMessageValue') !== false;
  130. addSuccessMessage(
  131. tct(
  132. showChangeText
  133. ? 'Changed [fieldName] from [oldValue] to [newValue]'
  134. : 'Changed [fieldName]',
  135. {
  136. root: <MessageContainer />,
  137. fieldName: <FieldName>{label}</FieldName>,
  138. oldValue: <FormValue>{prettifyValue(change.old)}</FormValue>,
  139. newValue: <FormValue>{prettifyValue(change.new)}</FormValue>,
  140. }
  141. ),
  142. {
  143. modelArg: {
  144. model,
  145. id: fieldName,
  146. undo: () => {
  147. if (!model || !fieldName) {
  148. return;
  149. }
  150. const oldValue = model.getValue(fieldName);
  151. const didUndo = model.undo();
  152. const newValue = model.getValue(fieldName);
  153. if (!didUndo) {
  154. return;
  155. }
  156. if (!label) {
  157. return;
  158. }
  159. // `saveField` can return null if it can't save
  160. const saveResult = model.saveField(fieldName, newValue);
  161. if (!saveResult) {
  162. addErrorMessage(
  163. tct(
  164. showChangeText
  165. ? 'Unable to restore [fieldName] from [oldValue] to [newValue]'
  166. : 'Unable to restore [fieldName]',
  167. {
  168. root: <MessageContainer />,
  169. fieldName: <FieldName>{label}</FieldName>,
  170. oldValue: <FormValue>{prettifyValue(oldValue)}</FormValue>,
  171. newValue: <FormValue>{prettifyValue(newValue)}</FormValue>,
  172. }
  173. )
  174. );
  175. return;
  176. }
  177. saveResult.then(() => {
  178. addMessage(
  179. tct(
  180. showChangeText
  181. ? 'Restored [fieldName] from [oldValue] to [newValue]'
  182. : 'Restored [fieldName]',
  183. {
  184. root: <MessageContainer />,
  185. fieldName: <FieldName>{label}</FieldName>,
  186. oldValue: <FormValue>{prettifyValue(oldValue)}</FormValue>,
  187. newValue: <FormValue>{prettifyValue(newValue)}</FormValue>,
  188. }
  189. ),
  190. 'undo',
  191. {
  192. duration: DEFAULT_TOAST_DURATION,
  193. }
  194. );
  195. });
  196. },
  197. },
  198. }
  199. );
  200. }
  201. const FormValue = styled('span')`
  202. font-style: italic;
  203. margin: 0 ${space(0.5)};
  204. `;
  205. const FieldName = styled('span')`
  206. font-weight: bold;
  207. margin: 0 ${space(0.5)};
  208. `;
  209. const MessageContainer = styled('div')`
  210. display: flex;
  211. align-items: center;
  212. `;