indicator.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. import {isValidElement} 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. if (typeof msg === 'string' || isValidElement(msg)) {
  74. return addMessageWithType('error')(msg, options);
  75. }
  76. // When non string, non-react element responses are passed, addErrorMessage
  77. // crashes the entire page because it falls outside any error
  78. // boundaries defined for the components on the page. Adding a fallback
  79. // to prevent page crashes.
  80. return addMessageWithType('error')(
  81. t(
  82. "You've hit an issue, fortunately we use Sentry to monitor Sentry. So it's likely we're already looking into this!"
  83. ),
  84. options
  85. );
  86. }
  87. export function addSuccessMessage(msg: React.ReactNode, options?: Options) {
  88. return addMessageWithType('success')(msg, options);
  89. }
  90. const PRETTY_VALUES: Map<unknown, string> = new Map([
  91. ['', '<empty>'],
  92. [null, '<none>'],
  93. [undefined, '<unset>'],
  94. // if we don't cast as any, then typescript complains because booleans are not valid keys
  95. [true as any, 'enabled'],
  96. [false as any, 'disabled'],
  97. ]);
  98. // Transform form values into a string
  99. // Otherwise bool values will not get rendered and empty strings look like a bug
  100. const prettyFormString = (val: ChangeValue, model: FormModel, fieldName: string) => {
  101. const descriptor = model.fieldDescriptor.get(fieldName);
  102. if (descriptor && typeof descriptor.formatMessageValue === 'function') {
  103. const initialData = model.initialData;
  104. // XXX(epurkhiser): We pass the "props" as the descriptor and initialData.
  105. // This isn't necessarily all of the props of the form field, but should
  106. // make up a good portion needed for formatting.
  107. return descriptor.formatMessageValue(val, {...descriptor, initialData});
  108. }
  109. if (PRETTY_VALUES.has(val)) {
  110. return PRETTY_VALUES.get(val);
  111. }
  112. return typeof val === 'object' ? val : String(val);
  113. };
  114. // Some fields have objects in them.
  115. // For example project key rate limits.
  116. type ChangeValue = FieldValue | Record<string, any>;
  117. type Change = {
  118. new: ChangeValue;
  119. old: ChangeValue;
  120. };
  121. /**
  122. * This will call an action creator to generate a "Toast" message that
  123. * notifies user the field that changed with its previous and current values.
  124. *
  125. * Also allows for undo
  126. */
  127. export function saveOnBlurUndoMessage(
  128. change: Change,
  129. model: FormModel,
  130. fieldName: string
  131. ) {
  132. if (!model) {
  133. return;
  134. }
  135. const label = model.getDescriptor(fieldName, 'label');
  136. if (!label) {
  137. return;
  138. }
  139. const prettifyValue = (val: ChangeValue) => prettyFormString(val, model, fieldName);
  140. // Hide the change text when formatMessageValue is explicitly set to false
  141. const showChangeText = model.getDescriptor(fieldName, 'formatMessageValue') !== false;
  142. addSuccessMessage(
  143. tct(
  144. showChangeText
  145. ? 'Changed [fieldName] from [oldValue] to [newValue]'
  146. : 'Changed [fieldName]',
  147. {
  148. root: <MessageContainer />,
  149. fieldName: <FieldName>{label}</FieldName>,
  150. oldValue: <FormValue>{prettifyValue(change.old)}</FormValue>,
  151. newValue: <FormValue>{prettifyValue(change.new)}</FormValue>,
  152. }
  153. ),
  154. {
  155. modelArg: {
  156. model,
  157. id: fieldName,
  158. undo: () => {
  159. if (!model || !fieldName) {
  160. return;
  161. }
  162. const oldValue = model.getValue(fieldName);
  163. const didUndo = model.undo();
  164. const newValue = model.getValue(fieldName);
  165. if (!didUndo) {
  166. return;
  167. }
  168. if (!label) {
  169. return;
  170. }
  171. // `saveField` can return null if it can't save
  172. const saveResult = model.saveField(fieldName, newValue);
  173. if (!saveResult) {
  174. addErrorMessage(
  175. tct(
  176. showChangeText
  177. ? 'Unable to restore [fieldName] from [oldValue] to [newValue]'
  178. : 'Unable to restore [fieldName]',
  179. {
  180. root: <MessageContainer />,
  181. fieldName: <FieldName>{label}</FieldName>,
  182. oldValue: <FormValue>{prettifyValue(oldValue)}</FormValue>,
  183. newValue: <FormValue>{prettifyValue(newValue)}</FormValue>,
  184. }
  185. )
  186. );
  187. return;
  188. }
  189. saveResult.then(() => {
  190. addMessage(
  191. tct(
  192. showChangeText
  193. ? 'Restored [fieldName] from [oldValue] to [newValue]'
  194. : 'Restored [fieldName]',
  195. {
  196. root: <MessageContainer />,
  197. fieldName: <FieldName>{label}</FieldName>,
  198. oldValue: <FormValue>{prettifyValue(oldValue)}</FormValue>,
  199. newValue: <FormValue>{prettifyValue(newValue)}</FormValue>,
  200. }
  201. ),
  202. 'undo',
  203. {
  204. duration: DEFAULT_TOAST_DURATION,
  205. }
  206. );
  207. });
  208. },
  209. },
  210. }
  211. );
  212. }
  213. const FormValue = styled('span')`
  214. font-style: italic;
  215. margin: 0 ${space(0.5)};
  216. `;
  217. const FieldName = styled('span')`
  218. font-weight: bold;
  219. margin: 0 ${space(0.5)};
  220. `;
  221. const MessageContainer = styled('div')`
  222. display: flex;
  223. align-items: center;
  224. `;