indicator.tsx 7.7 KB

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