indicator.tsx 7.8 KB

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