diff --git a/fixtures/page_objects/dashboard_detail.py b/fixtures/page_objects/dashboard_detail.py
index 2393667bcd61..22651bdfad86 100644
--- a/fixtures/page_objects/dashboard_detail.py
+++ b/fixtures/page_objects/dashboard_detail.py
@@ -83,7 +83,7 @@ def save_dashboard(self):
# until the API call finishes. Since the loading indicator isn't used
# we can't rely on self.wait_until_loaded(). The UI shows a
# success toast, however if a previous step of a test shows a success
- # toast, a wait_until([data-test-id="toast-success"]) will return
+ # toast, a wait for the status role will return
# immediately due to the previous toast still being in the DOM.
# Since clicking the save dasboard button is removed once the API
# call is complete, we can wait for that as a signal
diff --git a/fixtures/page_objects/issue_list.py b/fixtures/page_objects/issue_list.py
index b53e56983666..45985cafb530 100644
--- a/fixtures/page_objects/issue_list.py
+++ b/fixtures/page_objects/issue_list.py
@@ -30,8 +30,9 @@ def resolve_issues(self):
self.browser.click('[aria-label="Resolve"]')
def wait_for_issue_removal(self):
- self.browser.click_when_visible('[data-test-id="toast-success"]')
- self.browser.wait_until_not('[data-test-id="toast-success"]')
+ toast_selector = '[role="status"]'
+ self.browser.click_when_visible(f'{toast_selector} [aria-label="Dismiss"]')
+ self.browser.wait_until_not(toast_selector)
def wait_for_issue(self):
self.browser.wait_until('[data-test-id="group"]')
diff --git a/package.json b/package.json
index 5eed5e53f3c9..2bce0d99d89e 100644
--- a/package.json
+++ b/package.json
@@ -212,6 +212,7 @@
"remark-gfm": "^4.0.1",
"remark-mdx-frontmatter": "^5.2.0",
"screenfull": "^6.0.2",
+ "sonner": "2.0.8",
"sprintf-js": "1.0.3",
"style-loader": "4.0.0",
"swc-plugin-component-annotate": "1.18.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8647328bf056..7000e1ca753c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -500,6 +500,9 @@ importers:
screenfull:
specifier: ^6.0.2
version: 6.0.2
+ sonner:
+ specifier: 2.0.8
+ version: 2.0.8(@types/react@19.2.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
sprintf-js:
specifier: 1.0.3
version: 1.0.3
@@ -8110,6 +8113,16 @@ packages:
solid-js@1.9.14:
resolution: {integrity: sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ==}
+ sonner@2.0.8:
+ resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==}
+ peerDependencies:
+ '@types/react': ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
source-map-generator@2.0.2:
resolution: {integrity: sha512-unCl5BQhF/us51DiT7SvlSY3QUPhyfAdHJxd8l7FXdwzqxli0UDMV2dEuei2SeGp3Z4rB/AJ9zKi1mGOp2K2ww==}
engines: {node: '>=20'}
@@ -17370,6 +17383,13 @@ snapshots:
seroval: 1.5.6
seroval-plugins: 1.5.6(seroval@1.5.6)
+ sonner@2.0.8(@types/react@19.2.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.1
+
source-map-generator@2.0.2: {}
source-map-js@1.2.1: {}
diff --git a/static/app/actionCreators/indicator.tsx b/static/app/actionCreators/indicator.tsx
index ad6e30ad4d16..608201360450 100644
--- a/static/app/actionCreators/indicator.tsx
+++ b/static/app/actionCreators/indicator.tsx
@@ -1,10 +1,10 @@
import {isValidElement} from 'react';
import * as Sentry from '@sentry/react';
-import type {FormModel} from 'sentry/components/forms/model';
-import {DEFAULT_TOAST_DURATION} from 'sentry/constants';
+import {toast, type ToastOptions} from '@sentry/scraps/toast';
+
+import {IconRefresh} from 'sentry/icons';
import {t} from 'sentry/locale';
-import {IndicatorStore} from 'sentry/stores/indicatorStore';
import {isDemoModeActive} from 'sentry/utils/demoMode';
type IndicatorType = 'loading' | 'error' | 'success' | 'undo' | '';
@@ -18,27 +18,17 @@ interface IndicatorOptions {
type UndoIndicatorOptions = IndicatorOptions & {undo: () => void};
-interface UndoableIndicatorOptions extends IndicatorOptions {
- formModel: {
- id: string;
- model: FormModel;
- };
-}
-
-export type Indicator = {
- id: string | number;
- message: React.ReactNode;
- options: IndicatorOptions;
- type: IndicatorType;
- clearId?: null | number;
-};
-
// Clears all indicators
+/**
+ * @deprecated Use `toast.dismiss()` from `@sentry/scraps/toast` instead.
+ */
export function clearIndicators() {
- IndicatorStore.clear();
+ toast.dismiss();
}
-// Note previous IndicatorStore.add behavior was to default to "loading" if no type was supplied
+/**
+ * @deprecated Use the namespaced API from `@sentry/scraps/toast` instead.
+ */
export function addMessage(
msg: React.ReactNode,
type: 'undo',
@@ -54,7 +44,7 @@ export function addMessage(
type: IndicatorType,
options: IndicatorOptions = {}
): void {
- const {duration: optionsDuration, append, ...rest} = options;
+ const {duration: optionsDuration, disableDismiss, undo} = options;
// XXX: Debug for https://sentry.io/organizations/sentry/issues/1595204979/
if (
@@ -70,17 +60,43 @@ export function addMessage(
);
}
- // use default only if undefined, as 0 is a valid duration
- const duration =
- optionsDuration === undefined ? DEFAULT_TOAST_DURATION : optionsDuration;
+ const toastOptions: ToastOptions = {
+ dismissible: disableDismiss !== true,
+ };
- const action = append ? 'append' : 'add';
- // XXX: This differs from `IndicatorStore.add` since it won't return the indicator that is created
- // because we are firing an action. You can just add a new message and it will, by default,
- // replace active indicator
- IndicatorStore[action](msg, type, {...rest, duration});
+ if (optionsDuration !== undefined) {
+ toastOptions.duration =
+ optionsDuration === null || optionsDuration === 0 ? Infinity : optionsDuration;
+ }
+
+ if (typeof undo === 'function') {
+ toastOptions.action = {
+ label: t('Undo'),
+ icon: ,
+ onClick: undo,
+ };
+ }
+
+ switch (type) {
+ case 'loading':
+ toast.loading(msg, toastOptions);
+ break;
+ case 'error':
+ toast.error(msg, toastOptions);
+ break;
+ case 'success':
+ toast.success(msg, toastOptions);
+ break;
+ case 'undo':
+ case '':
+ toast.message(msg, toastOptions);
+ break;
+ }
}
+/**
+ * @deprecated Use `toast.loading()` from `@sentry/scraps/toast` instead.
+ */
export function addLoadingMessage(
msg: React.ReactNode = t('Saving changes...'),
options?: IndicatorOptions
@@ -88,6 +104,9 @@ export function addLoadingMessage(
return addMessage(msg, 'loading', options);
}
+/**
+ * @deprecated Use `toast.error()` from `@sentry/scraps/toast` instead.
+ */
export function addErrorMessage(msg: React.ReactNode, options?: IndicatorOptions) {
if (isDemoModeActive()) {
return addMessage(t('This action is not allowed in demo mode.'), 'error', options);
@@ -108,9 +127,12 @@ export function addErrorMessage(msg: React.ReactNode, options?: IndicatorOptions
);
}
+/**
+ * @deprecated Use `toast.success()` from `@sentry/scraps/toast` instead.
+ */
export function addSuccessMessage(
msg: React.ReactNode,
- options?: IndicatorOptions | UndoableIndicatorOptions
+ options?: IndicatorOptions | UndoIndicatorOptions
) {
return addMessage(msg, 'success', options);
}
diff --git a/static/app/bootstrap/processInitQueue.spec.tsx b/static/app/bootstrap/processInitQueue.spec.tsx
index b0c1d7c500cb..28bd08a14132 100644
--- a/static/app/bootstrap/processInitQueue.spec.tsx
+++ b/static/app/bootstrap/processInitQueue.spec.tsx
@@ -10,7 +10,6 @@ import {TeamFixture} from 'sentry-fixture/team';
import {screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
import {processInitQueue} from 'sentry/bootstrap/processInitQueue';
-import {IndicatorStore} from 'sentry/stores/indicatorStore';
import {SentryInitRenderReactComponent} from 'sentry/types/system';
describe('processInitQueue', () => {
@@ -45,21 +44,6 @@ describe('processInitQueue', () => {
expect(await screen.findByText('Very Strong')).toBeInTheDocument();
});
- it('renders indicators', async () => {
- window.__onSentryInit = [
- {
- component: SentryInitRenderReactComponent.INDICATORS,
- container: '#indicator-container',
- name: 'renderReact',
- },
- ];
-
- IndicatorStore.add('Indicator Alert', 'success');
-
- render(
);
- processInitQueue();
- expect(await screen.findByText('Indicator Alert')).toBeInTheDocument();
- });
it('renders setup wizard', async () => {
window.__onSentryInit = [
{
diff --git a/static/app/bootstrap/processInitQueue.tsx b/static/app/bootstrap/processInitQueue.tsx
index ddf4fd4fe084..12baf1e74547 100644
--- a/static/app/bootstrap/processInitQueue.tsx
+++ b/static/app/bootstrap/processInitQueue.tsx
@@ -19,8 +19,6 @@ import {renderOnDomReady} from './renderOnDomReady';
const queryClient = new QueryClient(DEFAULT_QUERY_CLIENT_CONFIG);
const COMPONENT_MAP = {
- [SentryInitRenderReactComponent.INDICATORS]: () =>
- import(/* webpackChunkName: "Indicators" */ 'sentry/components/indicators'),
[SentryInitRenderReactComponent.SETUP_WIZARD]: () =>
import(/* webpackChunkName: "SetupWizard" */ 'sentry/views/setupWizard'),
[SentryInitRenderReactComponent.WEB_AUTHN_ASSSERT]: () =>
diff --git a/static/app/components/core/toast/index.tsx b/static/app/components/core/toast/index.tsx
index 629e36e24415..f10e862da6ea 100644
--- a/static/app/components/core/toast/index.tsx
+++ b/static/app/components/core/toast/index.tsx
@@ -1 +1,4 @@
-export {Toast} from './toast';
+export {toast} from './toastApi';
+export {ToastProvider} from './toaster';
+export {DEFAULT_TOAST_DURATION} from './types';
+export type {ToastAction, ToastOptions, ToastVariant} from './types';
diff --git a/static/app/components/core/toast/toast.mdx b/static/app/components/core/toast/toast.mdx
index 1a19e7a4a865..31a858106289 100644
--- a/static/app/components/core/toast/toast.mdx
+++ b/static/app/components/core/toast/toast.mdx
@@ -1,6 +1,6 @@
---
title: Toast
-description: Temporary notification messages that provide feedback for user actions, with support for success, error, loading, and undoable states.
+description: Temporary notifications that provide feedback for user actions.
category: status
source: '@sentry/scraps/toast'
resources:
@@ -11,270 +11,119 @@ resources:
WAI-ARIA Alert: https://www.w3.org/WAI/ARIA/apg/patterns/alert/
---
-import {Toast} from '@sentry/scraps/toast';
+import {Button} from '@sentry/scraps/button';
+import {Flex} from '@sentry/scraps/layout';
+import {toast} from '@sentry/scraps/toast';
+import {IconRefresh} from 'sentry/icons';
import * as Storybook from 'sentry/stories';
export const documentation = import('!!type-loader!@sentry/scraps/toast');
-`` is a notification component that displays temporary messages to provide feedback for user actions. Toasts appear briefly to confirm actions, report errors, show loading states, or offer undo functionality.
+Use the namespaced `toast` API to show temporary notifications. The shared app provider renders the
+toaster, so callers only need to invoke a method.
-In practice, you won't typically render `` components directly. Instead, use the action creators `addSuccessMessage` and `addErrorMessage` from `static/app/actionCreators/indicator.tsx` to display toasts.
-
-```jsx
-import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
-
-// Show success toast
-addSuccessMessage('Project settings saved successfully');
+
+
+
+
+
+
+
+
-// Show error toast
-addErrorMessage('Failed to save settings. Please try again.');
+```tsx
+toast.success('Changes saved');
+toast.error('Could not save changes');
+toast.loading('Uploading file...', {duration: 2000});
+toast.message('This is a notification');
```
-## Toast Types
+## Variants
-Toasts come in four types, each serving a different purpose:
+The API provides four variants:
-- **success**: Confirms successful operations (green checkmark)
-- **error**: Reports errors or failures (red error icon)
-- **loading**: Indicates ongoing operations (spinner)
-- **undo**: Provides an undo action for reversible operations
-
-
- {}}
- />
- {}}
- />
- {}}
- />
- {}}
- />
-
+- `success` confirms a completed action.
+- `error` reports a failed action.
+- `loading` indicates work in progress.
+- `message` displays a neutral notification.
-```jsx
-// Success
-addSuccessMessage('Settings saved');
+Toasts use the current visual theme and keep the existing success, error, and loading icons.
-// Error
-addErrorMessage('Failed to load data');
+## Options
-// Loading
-addLoadingMessage('Uploading file...');
+Pass a `ToastOptions` object as the second argument:
-// Undo
-addSuccessMessage('Item deleted', {undo: handleUndo});
+```tsx
+toast.error('Could not save changes', {
+ duration: 10000,
+ dismissible: false,
+});
```
-## Undoable Actions
+- `duration` is in milliseconds. It defaults to 6000. Use `Infinity` for a persistent toast.
+- `dismissible` defaults to `true`. When `false`, the toast has no close button and ignores swipes.
+- `id` updates an existing toast with the same id instead of adding another toast.
+- `onDismiss` runs when Sonner dismisses the toast.
+
+## Actions
-For reversible actions, provide an `undo` callback in the options. This displays an "Undo" button in the toast.
+Use `action` for a button next to the message. The toast closes after the action runs.
-
- {},
- },
- }}
- onDismiss={() => {}}
- />
- {},
+
+ })
+ }
+ >
+ Show undo toast
+
-```jsx
-addSuccessMessage('Project deleted', {
- undo: () => {
- restoreProject(projectId);
+```tsx
+toast.success('Item deleted', {
+ action: {
+ label: 'Undo',
+ icon: ,
+ onClick: () => toast.message('Item restored'),
},
});
```
-## Dismissible
-
-All toasts are dismissible by default—users can click the "×" button to close them. Toasts also auto-dismiss after a few seconds (except loading toasts).
-
-```jsx
-addSuccessMessage('Your changes have been saved');
-// Toast appears and auto-dismisses after ~4 seconds
-```
-
-## Duration
-
-Control how long toasts remain visible using the `duration` option:
-
-```jsx
-// Short duration (2 seconds)
-addSuccessMessage('Copied to clipboard', {duration: 2000});
-
-// Longer duration (10 seconds)
-addErrorMessage('Important error message', {duration: 10000});
-
-// Persistent (no auto-dismiss)
-addLoadingMessage('Processing large file...', {duration: Infinity});
-```
-
-## Usage Patterns
-
-### Action Confirmations
-
-Confirm successful operations:
-
-```jsx
-const handleSave = async () => {
- try {
- await saveProject(data);
- addSuccessMessage('Project saved successfully');
- } catch (error) {
- addErrorMessage('Failed to save project');
- }
-};
-```
-
-### Error Reporting
+## Dismissing toasts
-Inform users of errors with actionable guidance:
+Toasts dismiss automatically after their duration. Users can also click the `Dismiss` button or swipe
+a dismissible toast. Toasts of the same variant stack up to three at once; showing a different variant
+dismisses the previous variant's active toasts.
-```jsx
-try {
- await uploadFile(file);
- addSuccessMessage(`${file.name} uploaded`);
-} catch (error) {
- addErrorMessage(`Failed to upload ${file.name}. File may be too large.`);
-}
-```
-
-### Deletions with Undo
-
-Allow users to undo destructive actions:
-
-```jsx
-const handleDelete = async id => {
- const backup = items.find(item => item.id === id);
- await deleteItem(id);
+```tsx
+// Dismiss one toast by id
+const id = toast.loading('Working...', {duration: Infinity});
+toast.dismiss(id);
- addSuccessMessage('Item deleted', {
- undo: async () => {
- await restoreItem(backup);
- addSuccessMessage('Item restored');
- },
- });
-};
+// Dismiss every toast
+toast.dismiss();
```
-### Loading States
-
-Show progress for long-running operations:
-
-```jsx
-const handleExport = async () => {
- addLoadingMessage('Generating export...');
-
- try {
- await generateExport();
- addSuccessMessage('Export complete');
- } catch (error) {
- addErrorMessage('Export failed');
- }
-};
-```
-
-## Best Practices
-
-**Message Content**
-
-- Keep messages concise (1-2 sentences maximum)
-- Use clear, specific language
-- Avoid technical jargon
-- Include actionable next steps for errors
-
-**Frequency**
-
-- Don't overwhelm users with too many toasts
-- Batch similar notifications when possible
-- Use toasts for important feedback, not every minor action
-
-**Timing**
-
-- Success toasts: 3-4 seconds (default)
-- Error toasts: 6-8 seconds (users need time to read)
-- Loading toasts: Until operation completes
-- Undo toasts: 5-8 seconds (give time to react)
-
## Accessibility
-Toasts use the [WAI-ARIA Alert pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alert/) for announcing messages to screen readers:
-
-- [2.2.4 Interruptions](https://www.w3.org/TR/WCAG22/#interruptions): Toasts can be dismissed and auto-dismiss
-- Screen readers announce toast content when it appears
-- Undo buttons are keyboard accessible
-
-### Developer Responsibilities
-
-**Message Clarity**
-
-- Write clear, specific messages
-- Ensure messages make sense when read aloud
-- Avoid relying solely on icons to convey meaning
-
-**Timing**
-
-- Allow sufficient time for users to read and react
-- Don't auto-dismiss error messages too quickly
-- Give adequate time to click undo buttons
-
-**Frequency Control**
-
-- Avoid showing multiple toasts simultaneously
-- Queue or batch notifications when appropriate
-- Don't interrupt critical user workflows with non-urgent toasts
-
-**Undo Actions**
+The toaster uses a polite live region to announce new notifications. Error toasts use the `alert`
+role. Other toasts use the `status` role.
-- Ensure undo callbacks work correctly
-- Provide feedback when undo completes
-- Handle errors in undo operations
+### Developer responsibilities
-For more information, see the [WAI-ARIA Alert practices](https://www.w3.org/WAI/ARIA/apg/patterns/alert/).
+- Write short, clear messages that make sense when read aloud.
+- Do not use only an icon or color to communicate the result.
+- Give users enough time to read the message and use its action. Use `duration: Infinity` when the
+ toast must stay visible.
+- Avoid sending many toasts at the same time. Group repeated notifications when possible.
+- Give each action a clear label and show feedback after the action runs.
+- Use persistent inline feedback for critical information that users must review.
diff --git a/static/app/components/core/toast/toast.spec.tsx b/static/app/components/core/toast/toast.spec.tsx
new file mode 100644
index 000000000000..e0d42a33f23f
--- /dev/null
+++ b/static/app/components/core/toast/toast.spec.tsx
@@ -0,0 +1,136 @@
+import {
+ act,
+ render,
+ screen,
+ userEvent,
+ waitFor,
+ waitForElementToBeRemoved,
+} from 'sentry-test/reactTestingLibrary';
+
+import {toast} from '@sentry/scraps/toast';
+
+describe('Toast', () => {
+ it.each([
+ ['success', 'status', 'Success', () => toast.success('Success')],
+ ['error', 'alert', 'Error', () => toast.error('Error')],
+ ['loading', 'status', 'Loading', () => toast.loading('Loading')],
+ ['default', 'status', 'Message', () => toast.message('Message')],
+ ] as const)('renders the %s variant', async (_variant, role, message, showToast) => {
+ render();
+
+ act(() => void showToast());
+
+ expect(await screen.findByRole(role)).toHaveTextContent(message);
+ });
+
+ it('does not dismiss when the toast body is clicked', async () => {
+ render();
+ act(() => void toast.message('Dismiss me', {duration: Infinity}));
+
+ const toastElement = await screen.findByRole('status');
+ await userEvent.click(toastElement);
+
+ expect(toastElement).toBeInTheDocument();
+ });
+
+ it('dismisses when the close button is clicked', async () => {
+ render();
+ act(() => void toast.message('Dismiss me', {duration: Infinity}));
+
+ await userEvent.click(await screen.findByRole('button', {name: 'Dismiss'}));
+
+ await waitForElementToBeRemoved(() => screen.queryByRole('status'));
+ });
+
+ it('does not dismiss when dismissible is false', async () => {
+ render();
+ act(() => void toast.message('Keep me', {duration: Infinity, dismissible: false}));
+
+ const toastElement = await screen.findByRole('status');
+ await userEvent.click(toastElement);
+
+ expect(toastElement).toBeInTheDocument();
+ expect(screen.queryByRole('button', {name: 'Dismiss'})).not.toBeInTheDocument();
+ });
+
+ it('dismisses all toasts', async () => {
+ render();
+ act(() => {
+ toast.error('First error', {duration: Infinity});
+ toast.error('Second error', {duration: Infinity});
+ toast.error('Third error', {duration: Infinity});
+ });
+
+ expect(await screen.findByText('First error')).toBeInTheDocument();
+ expect(screen.getByText('Second error')).toBeInTheDocument();
+ expect(screen.getByText('Third error')).toBeInTheDocument();
+
+ act(() => void toast.dismiss());
+
+ await waitFor(() => {
+ expect(screen.queryByText('First error')).not.toBeInTheDocument();
+ expect(screen.queryByText('Second error')).not.toBeInTheDocument();
+ expect(screen.queryByText('Third error')).not.toBeInTheDocument();
+ });
+ });
+
+ it('dismisses automatically after the configured duration', async () => {
+ jest.useFakeTimers();
+
+ try {
+ render();
+ act(() => void toast.message('Temporary', {duration: 1000}));
+ expect(await screen.findByRole('status')).toHaveTextContent('Temporary');
+
+ act(() => jest.advanceTimersByTime(1000));
+ act(() => jest.runAllTimers());
+
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('stacks toasts of the same variant', async () => {
+ render();
+ act(() => {
+ toast.error('First error', {duration: Infinity});
+ toast.error('Second error', {duration: Infinity});
+ toast.error('Third error', {duration: Infinity});
+ });
+
+ expect(await screen.findByText('First error')).toBeInTheDocument();
+ expect(screen.getByText('Second error')).toBeInTheDocument();
+ expect(screen.getByText('Third error')).toBeInTheDocument();
+ expect(screen.getAllByRole('alert')).toHaveLength(3);
+ });
+
+ it('dismisses toasts when the variant changes', async () => {
+ render();
+ act(() => void toast.loading('Loading', {duration: Infinity}));
+
+ expect(await screen.findByText('Loading')).toBeInTheDocument();
+
+ act(() => void toast.success('Success', {duration: Infinity}));
+
+ await waitForElementToBeRemoved(() => screen.queryByText('Loading'));
+ expect(screen.getByText('Success')).toBeInTheDocument();
+ });
+
+ it('runs an action and dismisses the toast', async () => {
+ const onClick = jest.fn();
+ render();
+ act(
+ () =>
+ void toast.message('Undoable', {
+ duration: Infinity,
+ action: {label: 'Undo', onClick},
+ })
+ );
+
+ await userEvent.click(await screen.findByRole('button', {name: 'Undo'}));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ await waitForElementToBeRemoved(() => screen.queryByText('Undoable'));
+ });
+});
diff --git a/static/app/components/core/toast/toast.tsx b/static/app/components/core/toast/toast.tsx
index ac1ddc72e37c..56fabe2eb613 100644
--- a/static/app/components/core/toast/toast.tsx
+++ b/static/app/components/core/toast/toast.tsx
@@ -1,100 +1,93 @@
import styled from '@emotion/styled';
-import * as Sentry from '@sentry/react';
-import classNames from 'classnames';
-import {motion, type HTMLMotionProps, type Transition} from 'framer-motion';
import {Button} from '@sentry/scraps/button';
import {Container, Flex} from '@sentry/scraps/layout';
import {useTranslation} from '@sentry/scraps/translationContext';
-import type {Indicator} from 'sentry/actionCreators/indicator';
import {LoadingIndicator} from 'sentry/components/loadingIndicator';
import {TextOverflow} from 'sentry/components/textOverflow';
-import {IconCheckmark, IconRefresh, IconWarning} from 'sentry/icons';
+import {IconCheckmark, IconClose, IconWarning} from 'sentry/icons';
import type {Theme} from 'sentry/utils/theme';
+import {unreachable} from 'sentry/utils/unreachable';
+
+import type {ToastAction, ToastVariant} from './types';
interface ToastProps {
- indicator: Indicator;
- onDismiss: (indicator: Indicator, event: React.MouseEvent) => void;
+ message: React.ReactNode;
+ variant: ToastVariant;
+ action?: ToastAction;
+ onDismiss?: () => void;
}
-export function Toast({indicator, onDismiss, ...props}: ToastProps) {
+export function Toast({message, variant, action, onDismiss}: ToastProps) {
const {t} = useTranslation();
return (
- onDismiss(indicator, e)
- }
- data-test-id={indicator.type ? `toast-${indicator.type}` : 'toast'}
- className={classNames('ref-toast', `ref-${indicator.type}`)}
- type={indicator.type}
- {...TOAST_TRANSITION}
- {...props}
- >
-
+
+
- {indicator.message}
+ {message}
- {indicator.options.undo && typeof indicator.options.undo === 'function' ? (
+ {action ? (
}
+ onClick={event => {
+ event.stopPropagation();
+ action.onClick();
+ onDismiss?.();
+ }}
+ icon={action.icon}
>
- {t('Undo')}
+ {action.label}
) : null}
+ {onDismiss ? (
+
+ }
+ onClick={onDismiss}
+ />
+
+ ) : null}
);
}
-const TOAST_TRANSITION = {
- initial: {opacity: 0, y: 70},
- animate: {opacity: 1, y: 0},
- exit: {opacity: 0, y: 70},
- transition: {
- type: 'spring',
- stiffness: 450,
- damping: 25,
- } satisfies Transition,
-};
-
-function ToastIcon({type}: {type: Indicator['type']}) {
- switch (type) {
+function ToastIcon({variant}: {variant: ToastVariant}) {
+ switch (variant) {
case 'loading':
return (
-
+
);
case 'success':
return (
-
+
);
case 'error':
return (
-
+
);
- case 'undo':
- return null;
- case '':
+ case 'default':
return null;
default:
- Sentry.captureException(new Error(`Unknown toast type: ${type}`));
- return null;
+ return unreachable(variant);
}
}
-function getContainerTheme(theme: Theme, type: Indicator['type']): React.CSSProperties {
- switch (type) {
+function getContainerTheme(theme: Theme, variant: ToastVariant): React.CSSProperties {
+ switch (variant) {
case 'success':
return {
background: theme.tokens.background.transparent.success.muted,
@@ -119,21 +112,21 @@ function getContainerTheme(theme: Theme, type: Indicator['type']): React.CSSProp
}
}
-interface ToastContainerProps extends HTMLMotionProps<'div'> {
+interface ToastContainerProps extends React.HTMLAttributes {
children: React.ReactNode;
- type: Indicator['type'];
+ variant: ToastVariant;
}
const ToastContainer = styled((props: ToastContainerProps) => {
- const {type, children, ...rest} = props;
+ const {variant, children, ...rest} = props;
return (
-
- {children}
+
+ {children}
);
})``;
-const ToastOuterContainer = styled(motion.div)<{type: Indicator['type']}>`
+const ToastOuterContainer = styled('div')<{variant: ToastVariant}>`
overflow: hidden;
/* The outer container is a separate element because the colors are not opaque,
* so we set the background color here to the background color so that the
@@ -141,21 +134,21 @@ const ToastOuterContainer = styled(motion.div)<{type: Indicator['type']}>`
*/
background: ${p => p.theme.tokens.background.primary};
border-radius: ${p => p.theme.radius.lg};
- border: ${p => getContainerTheme(p.theme, p.type).border};
- box-shadow: ${p => getContainerTheme(p.theme, p.type).boxShadow};
+ border: ${p => getContainerTheme(p.theme, p.variant).border};
+ box-shadow: ${p => getContainerTheme(p.theme, p.variant).boxShadow};
`;
-const ToastInnerContainer = styled('div')<{type: Indicator['type']}>`
+const ToastInnerContainer = styled('div')<{variant: ToastVariant}>`
display: flex;
align-items: stretch;
- background: ${p => getContainerTheme(p.theme, p.type).background};
+ background: ${p => getContainerTheme(p.theme, p.variant).background};
`;
function getToastIconContainerTheme(
theme: Theme,
- type: Indicator['type']
+ variant: ToastVariant
): React.CSSProperties {
- switch (type) {
+ switch (variant) {
case 'success':
return {
background: theme.tokens.background.success.vibrant,
@@ -173,21 +166,21 @@ function getToastIconContainerTheme(
};
}
}
-const ToastIconContainer = styled('div')<{type: Indicator['type']}>`
+const ToastIconContainer = styled('div')<{variant: ToastVariant}>`
display: flex;
align-items: center;
justify-content: center;
padding: ${p => p.theme.space.lg} ${p => p.theme.space.xl};
position: relative;
- ${p => ({...getToastIconContainerTheme(p.theme, p.type)})};
+ ${p => ({...getToastIconContainerTheme(p.theme, p.variant)})};
svg {
width: 16px;
height: 16px;
color: ${p =>
- p.type === 'success'
+ p.variant === 'success'
? p.theme.tokens.content.onVibrant.dark
- : p.type === 'error'
+ : p.variant === 'error'
? p.theme.tokens.content.onVibrant.light
: undefined} !important;
}
diff --git a/static/app/components/core/toast/toastApi.tsx b/static/app/components/core/toast/toastApi.tsx
new file mode 100644
index 000000000000..2dadb06c0661
--- /dev/null
+++ b/static/app/components/core/toast/toastApi.tsx
@@ -0,0 +1,86 @@
+import type {ReactNode} from 'react';
+import {toast as sonnerToast} from 'sonner';
+
+import {Toast} from './toast';
+import type {ToastOptions, ToastVariant} from './types';
+
+type ToastId = string | number;
+
+const activeToastIds = new Map>();
+
+function removeActiveToast(variant: ToastVariant, toastId: ToastId) {
+ const ids = activeToastIds.get(variant);
+ ids?.delete(toastId);
+
+ if (ids?.size === 0) {
+ activeToastIds.delete(variant);
+ }
+}
+
+function dismissOtherVariants(variant: ToastVariant) {
+ for (const [activeVariant, ids] of activeToastIds) {
+ if (activeVariant === variant) {
+ continue;
+ }
+
+ for (const toastId of ids) {
+ sonnerToast.dismiss(toastId);
+ }
+ activeToastIds.delete(activeVariant);
+ }
+}
+
+function show(variant: ToastVariant, message: ReactNode, options: ToastOptions = {}) {
+ const {action, dismissible = true, duration, id, onDismiss} = options;
+
+ dismissOtherVariants(variant);
+
+ const toastId = sonnerToast.custom(
+ renderedToastId => (
+ sonnerToast.dismiss(renderedToastId) : undefined}
+ />
+ ),
+ {
+ duration,
+ dismissible,
+ onDismiss: dismissedToast => {
+ removeActiveToast(variant, dismissedToast.id);
+ onDismiss?.();
+ },
+ onAutoClose: dismissedToast => removeActiveToast(variant, dismissedToast.id),
+ ...(id === undefined ? {} : {id}),
+ }
+ );
+
+ const ids = activeToastIds.get(variant) ?? new Set();
+ ids.add(toastId);
+ activeToastIds.set(variant, ids);
+
+ return toastId;
+}
+
+export const toast = {
+ success: (message: ReactNode, options?: ToastOptions) =>
+ show('success', message, options),
+ error: (message: ReactNode, options?: ToastOptions) => show('error', message, options),
+ loading: (message: ReactNode, options?: ToastOptions) =>
+ show('loading', message, options),
+ message: (message: ReactNode, options?: ToastOptions) =>
+ show('default', message, options),
+ /** Dismisses one toast, or every toast when called with no id. */
+ dismiss: (id?: ToastId) => {
+ if (id === undefined) {
+ activeToastIds.clear();
+ } else {
+ for (const variant of activeToastIds.keys()) {
+ removeActiveToast(variant, id);
+ }
+ }
+
+ return sonnerToast.dismiss(id);
+ },
+};
diff --git a/static/app/components/core/toast/toaster.tsx b/static/app/components/core/toast/toaster.tsx
new file mode 100644
index 000000000000..b851b59e1cab
--- /dev/null
+++ b/static/app/components/core/toast/toaster.tsx
@@ -0,0 +1,44 @@
+import type {ReactNode} from 'react';
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+import {Toaster} from 'sonner';
+
+import {useTranslation} from '@sentry/scraps/translationContext';
+
+import {DEFAULT_TOAST_DURATION} from './types';
+
+const StyledToaster = styled(Toaster)`
+ &[data-sonner-toaster] {
+ z-index: ${p => p.theme.zIndex.toast};
+ width: auto;
+ }
+
+ &[data-sonner-toaster] [data-sonner-toast] {
+ width: auto;
+ max-width: min(600px, calc(100vw - 60px));
+ }
+
+ /* Sonner hides overflow toasts with opacity and pointer-events, which leaves
+ * them in the accessibility tree and tab order. */
+ &[data-sonner-toaster] [data-sonner-toast][data-visible='false'] {
+ visibility: hidden;
+ }
+`;
+
+export function ToastProvider({children}: {children?: ReactNode}) {
+ const {t} = useTranslation();
+
+ return (
+
+ {children}
+
+
+ );
+}
diff --git a/static/app/components/core/toast/types.tsx b/static/app/components/core/toast/types.tsx
new file mode 100644
index 000000000000..bdffbdcdde35
--- /dev/null
+++ b/static/app/components/core/toast/types.tsx
@@ -0,0 +1,23 @@
+import type {ReactNode} from 'react';
+
+export const DEFAULT_TOAST_DURATION = 6000;
+
+export type ToastVariant = 'success' | 'error' | 'loading' | 'default';
+
+export interface ToastAction {
+ label: ReactNode;
+ onClick: () => void;
+ icon?: ReactNode;
+}
+
+export interface ToastOptions {
+ /** Renders a button next to the message. The toast dismisses after onClick. */
+ action?: ToastAction;
+ /** Defaults to true. When false the toast has no close button and ignores swipe. */
+ dismissible?: boolean;
+ /** ms. Use `Infinity` to keep the toast until it is dismissed. */
+ duration?: number;
+ /** Pass an existing id to replace that toast instead of adding one. */
+ id?: string | number;
+ onDismiss?: () => void;
+}
diff --git a/static/app/components/forms/formIndicators.tsx b/static/app/components/forms/formIndicators.tsx
index 95b05df9a06d..745220075267 100644
--- a/static/app/components/forms/formIndicators.tsx
+++ b/static/app/components/forms/formIndicators.tsx
@@ -8,7 +8,6 @@ import {
addSuccessMessage,
} from 'sentry/actionCreators/indicator';
import type {FieldValue, FormModel} from 'sentry/components/forms/model';
-import {DEFAULT_TOAST_DURATION} from 'sentry/constants';
import {tct} from 'sentry/locale';
/**
@@ -54,10 +53,6 @@ export function addUndoableFormChangeMessage(
? tct('Changed [fieldName] from [oldValue] to [newValue]', tctArgsSuccess)
: tct('Changed [fieldName]', tctArgsSuccess),
{
- formModel: {
- model,
- id: fieldName,
- },
undo: () => {
if (!model || !fieldName) {
return;
@@ -103,10 +98,7 @@ export function addUndoableFormChangeMessage(
showChangeText
? tct('Restored [fieldName] from [oldValue] to [newValue]', tctArgsRestored)
: tct('Restored [fieldName]', tctArgsRestored),
- 'success',
- {
- duration: DEFAULT_TOAST_DURATION,
- }
+ 'success'
);
});
},
diff --git a/static/app/components/indicators.spec.tsx b/static/app/components/indicators.spec.tsx
deleted file mode 100644
index af1d1d16cc23..000000000000
--- a/static/app/components/indicators.spec.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
-
-import {
- addErrorMessage,
- addMessage,
- addSuccessMessage,
- clearIndicators,
- type Indicator,
-} from 'sentry/actionCreators/indicator';
-import Indicators from 'sentry/components/indicators';
-import {IndicatorStore} from 'sentry/stores/indicatorStore';
-
-// Make sure we use `duration: null` to test add/remove
-
-jest.mock('framer-motion', () => ({
- ...jest.requireActual('framer-motion'),
- AnimatePresence: jest.fn(({children}) => children),
-}));
-
-describe('Indicators', () => {
- beforeEach(() => {
- act(() => clearIndicators());
- });
-
- it('renders nothing by default', () => {
- const {container} = render();
- expect(container).toHaveTextContent('');
- });
-
- it('has a loading indicator by default', () => {
- const {container} = render();
- // when "type" is empty, we should treat it as loading state
-
- act(() => void IndicatorStore.add('Loading'));
- expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
- expect(container).toHaveTextContent('Loading');
- });
-
- it('adds and removes a toast by calling IndicatorStore directly', () => {
- const {container} = render();
-
- // when "type" is empty, we should treat it as loading state
- let indicator!: Indicator;
- act(() => {
- indicator = IndicatorStore.add('Loading');
- });
-
- expect(container).toHaveTextContent('Loading');
-
- // Old indicator gets replaced when a new one is added
- act(() => IndicatorStore.remove(indicator));
- expect(container).toHaveTextContent('');
- });
-
- // This is a common pattern used throughout the code for API calls
- it('adds and replaces toast by calling IndicatorStore directly', () => {
- const {container} = render();
-
- act(() => void IndicatorStore.add('Loading'));
- expect(container).toHaveTextContent('Loading');
-
- // Old indicator gets replaced when a new one is added
- act(() => void IndicatorStore.add('success', 'success'));
- expect(container).toHaveTextContent('success');
- });
-
- it('does not have loading indicator when "type" is empty (default)', () => {
- const {container} = render();
-
- act(() => addMessage('Loading', '', {duration: null}));
- expect(container).toHaveTextContent('Loading');
- expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
- });
-
- it('has a loading indicator when type is "loading"', () => {
- const {container} = render();
-
- act(() => addMessage('Loading', 'loading', {duration: null}));
- expect(container).toHaveTextContent('Loading');
- expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
- });
-
- it('adds and removes toast by calling action creators', () => {
- const {container} = render();
-
- // action creators don't return anything
- act(() => addMessage('Loading', '', {duration: null}));
- expect(container).toHaveTextContent('Loading');
-
- // If no indicator is specified, will remove all indicators
- act(() => clearIndicators());
- expect(container).toHaveTextContent('');
- expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
- });
-
- it('adds and replaces toast by calling action creators', () => {
- const {container} = render();
-
- act(() => addMessage('Loading', '', {duration: null}));
- expect(container).toHaveTextContent('Loading');
-
- // Old indicator gets replaced when a new one is added
- act(() => addMessage('success', 'success', {duration: null}));
- expect(container).toHaveTextContent('success');
- expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
- });
-
- it('adds and replaces toasts by calling action creators helpers', async () => {
- const {container} = render();
-
- // Old indicator gets replaced when a new one is added
- act(() => addSuccessMessage('success'));
-
- await waitFor(() => {
- expect(container).toHaveTextContent('success');
- });
-
- act(() => clearIndicators());
- act(() => addErrorMessage('error'));
- await waitFor(() => {
- expect(container).toHaveTextContent('error');
- });
- });
-
- it('appends toasts', () => {
- const {container} = render();
-
- act(() => addMessage('Loading', '', {append: true, duration: null}));
- expect(screen.getByTestId('toast')).toHaveTextContent('Loading');
-
- act(() => addMessage('Success', 'success', {append: true, duration: null}));
- // Toasts get appended to the end
- expect(screen.getByTestId('toast')).toHaveTextContent('Loading');
- expect(screen.getByTestId('toast-success')).toHaveTextContent('Success');
-
- act(() => addMessage('Error', 'error', {append: true, duration: null}));
- // Toasts get appended to the end
- expect(screen.getByTestId('toast')).toHaveTextContent('Loading');
- expect(screen.getByTestId('toast-success')).toHaveTextContent('Success');
- expect(screen.getByTestId('toast-error')).toHaveTextContent('Error');
-
- // clears all toasts
- act(() => clearIndicators());
- expect(container).toHaveTextContent('');
- expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
- });
-
- it('dismisses on click', async () => {
- const {container} = render();
-
- act(() => addMessage('Loading', '', {append: true, duration: null}));
- expect(screen.getByTestId('toast')).toHaveTextContent('Loading');
-
- await userEvent.click(screen.getByTestId('toast'));
- expect(container).toHaveTextContent('');
- expect(screen.queryByTestId('toast')).not.toBeInTheDocument();
- });
-
- it('hides after 10s', () => {
- jest.useFakeTimers();
- const {container} = render();
-
- act(() => addMessage('Duration', '', {append: true, duration: 10000}));
- act(() => jest.advanceTimersByTime(9000));
- expect(screen.getByTestId('toast')).toHaveTextContent('Duration');
-
- // Still visible
- act(() => jest.advanceTimersByTime(999));
- expect(screen.getByTestId('toast')).toHaveTextContent('Duration');
-
- act(() => jest.advanceTimersByTime(2));
- expect(container).toHaveTextContent('');
- expect(screen.queryByTestId('toast')).not.toBeInTheDocument();
- jest.useRealTimers();
- });
-});
diff --git a/static/app/components/indicators.tsx b/static/app/components/indicators.tsx
deleted file mode 100644
index 4d930525bac1..000000000000
--- a/static/app/components/indicators.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import styled from '@emotion/styled';
-import {AnimatePresence} from 'framer-motion';
-
-import {Toast} from '@sentry/scraps/toast';
-
-import {IndicatorStore} from 'sentry/stores/indicatorStore';
-import {useLegacyStore} from 'sentry/stores/useLegacyStore';
-
-type Props = {
- className?: string;
-};
-
-function Indicators(props: Props) {
- const items = useLegacyStore(IndicatorStore);
-
- return (
-
- {/*
- * "wait": The entering child will wait until the exiting child has animated out.
- * Currently only renders a single child at a time.
- * @link https://www.framer.com/motion/animate-presence/###mode
- */}
-
- {items.map(indicator => (
- IndicatorStore.remove(indicator)}
- indicator={indicator}
- />
- ))}
-
-
- );
-}
-
-export default Indicators;
-
-const Toasts = styled('div')`
- position: fixed;
- right: calc(30px + var(--scrollbar-size, 0px));
- bottom: 30px;
- z-index: ${p => p.theme.zIndex.toast};
-`;
diff --git a/static/app/components/modals/sentryAppPublishRequestModal/sentryAppPublishRequestModal.spec.tsx b/static/app/components/modals/sentryAppPublishRequestModal/sentryAppPublishRequestModal.spec.tsx
index 09b9e57891b0..4cf1c2166e0a 100644
--- a/static/app/components/modals/sentryAppPublishRequestModal/sentryAppPublishRequestModal.spec.tsx
+++ b/static/app/components/modals/sentryAppPublishRequestModal/sentryAppPublishRequestModal.spec.tsx
@@ -1,5 +1,4 @@
import type {PropsWithChildren} from 'react';
-import {Fragment} from 'react';
import styled from '@emotion/styled';
import {OrganizationFixture} from 'sentry-fixture/organization';
import {SentryAppFixture} from 'sentry-fixture/sentryApp';
@@ -8,7 +7,6 @@ import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
import {makeCloseButton} from '@sentry/scraps/modal';
-import Indicators from 'sentry/components/indicators';
import {SentryAppPublishRequestModal} from 'sentry/components/modals/sentryAppPublishRequestModal/sentryAppPublishRequestModal';
describe('SentryAppDetailsModal', () => {
@@ -235,19 +233,16 @@ describe('SentryAppDetailsModal', () => {
const closeModal = jest.fn();
render(
-
-
- {p.children}}
- Footer={styledWrapper()}
- Body={styledWrapper()}
- CloseButton={makeCloseButton(() => {})}
- organization={OrganizationFixture()}
- app={sentryApp}
- onPublishSubmission={jest.fn()}
- />
-
+ {p.children}}
+ Footer={styledWrapper()}
+ Body={styledWrapper()}
+ CloseButton={makeCloseButton(() => {})}
+ organization={OrganizationFixture()}
+ app={sentryApp}
+ onPublishSubmission={jest.fn()}
+ />
);
// Fill out the form fields
diff --git a/static/app/constants/index.tsx b/static/app/constants/index.tsx
index 7c346d31d7fc..1a01fcc29aef 100644
--- a/static/app/constants/index.tsx
+++ b/static/app/constants/index.tsx
@@ -200,7 +200,6 @@ export const SENTRY_APP_PERMISSIONS: PermissionObj[] = [
},
];
-export const DEFAULT_TOAST_DURATION = 6000;
export const DEFAULT_DEBOUNCE_DURATION = 300;
// sentry.io project ID for seer-agents.
diff --git a/static/app/scrapsProviders/index.tsx b/static/app/scrapsProviders/index.tsx
index e4db929534a3..5eeef0e91794 100644
--- a/static/app/scrapsProviders/index.tsx
+++ b/static/app/scrapsProviders/index.tsx
@@ -1,3 +1,4 @@
+import {ToastProvider} from '@sentry/scraps/toast';
import {TranslationContextProvider} from '@sentry/scraps/translationContext';
import {t, tct} from 'sentry/locale';
@@ -13,7 +14,9 @@ export function ScrapsProviders({children}: {children: React.ReactNode}) {
- {children}
+
+ {children}
+
diff --git a/static/app/stores/groupStore.spec.tsx b/static/app/stores/groupStore.spec.tsx
index 3b3683c179fc..16dadc2b8be7 100644
--- a/static/app/stores/groupStore.spec.tsx
+++ b/static/app/stores/groupStore.spec.tsx
@@ -2,8 +2,9 @@ import {ActorFixture} from 'sentry-fixture/actor';
import {GroupFixture} from 'sentry-fixture/group';
import {ProjectFixture} from 'sentry-fixture/project';
+import {toast} from '@sentry/scraps/toast';
+
import {GroupStore} from 'sentry/stores/groupStore';
-import {IndicatorStore} from 'sentry/stores/indicatorStore';
import type {TimeseriesValue} from 'sentry/types/core';
import type {Group, GroupStats} from 'sentry/types/group';
@@ -230,30 +231,30 @@ describe('GroupStore', () => {
});
it('should show generic message when itemIds is undefined', () => {
- const addMessageSpy = jest.spyOn(IndicatorStore, 'addMessage');
+ const successToastSpy = jest.spyOn(toast, 'success');
GroupStore.onDeleteSuccess('1337', undefined, {});
- expect(addMessageSpy).toHaveBeenCalledWith('Deleted selected issues', 'success', {
+ expect(successToastSpy).toHaveBeenCalledWith('Deleted selected issues', {
duration: 4000,
});
});
it('should show specific count when itemIds is provided', () => {
- const addMessageSpy = jest.spyOn(IndicatorStore, 'addMessage');
+ const successToastSpy = jest.spyOn(toast, 'success');
GroupStore.onDeleteSuccess('1337', ['1', '2'], {});
- expect(addMessageSpy).toHaveBeenCalledWith('Deleted 2 Issues', 'success', {
+ expect(successToastSpy).toHaveBeenCalledWith('Deleted 2 Issues', {
duration: 4000,
});
});
it('should show shortId for single issue deletion', () => {
- const addMessageSpy = jest.spyOn(IndicatorStore, 'addMessage');
+ const successToastSpy = jest.spyOn(toast, 'success');
const mockGroup = g('1', {shortId: 'ABC-123'});
jest.spyOn(GroupStore, 'get').mockReturnValue(mockGroup);
GroupStore.onDeleteSuccess('1337', ['1'], {});
- expect(addMessageSpy).toHaveBeenCalledWith('Deleted ABC-123', 'success', {
+ expect(successToastSpy).toHaveBeenCalledWith('Deleted ABC-123', {
duration: 4000,
});
});
diff --git a/static/app/stores/groupStore.tsx b/static/app/stores/groupStore.tsx
index 5fbe03c629d6..237ed0ddbe36 100644
--- a/static/app/stores/groupStore.tsx
+++ b/static/app/stores/groupStore.tsx
@@ -1,8 +1,8 @@
import {createStore} from 'reflux';
-import type {Indicator} from 'sentry/actionCreators/indicator';
+import {toast} from '@sentry/scraps/toast';
+
import {t} from 'sentry/locale';
-import {IndicatorStore} from 'sentry/stores/indicatorStore';
import type {BaseGroup, Group, GroupStats} from 'sentry/types/group';
import {toArray} from 'sentry/utils/array/toArray';
import {parseApiError} from 'sentry/utils/parseApiError';
@@ -10,8 +10,8 @@ import type {RequestError} from 'sentry/utils/requestError/requestError';
import type {StrictStoreDefinition} from './types';
-function showAlert(msg: string, type: Indicator['type']) {
- IndicatorStore.addMessage(msg, type, {duration: 4000});
+function showAlert(msg: string, type: 'error' | 'success') {
+ toast[type](msg, {duration: 4000});
}
type ChangeId = string;
diff --git a/static/app/stores/indicatorStore.tsx b/static/app/stores/indicatorStore.tsx
deleted file mode 100644
index 1565be9156ba..000000000000
--- a/static/app/stores/indicatorStore.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import {createStore} from 'reflux';
-
-import type {Indicator} from 'sentry/actionCreators/indicator';
-import {t} from 'sentry/locale';
-
-import type {StrictStoreDefinition} from './types';
-
-interface InternalDefinition {
- lastId: number;
-}
-interface IndicatorStoreDefinition
- extends StrictStoreDefinition, InternalDefinition {
- /**
- * When this method is called directly via older parts of the application,
- * we want to maintain the old behavior in that it is replaced (and not queued up)
- *
- * @param message Toast message to be displayed
- * @param type One of ['error', 'success', '']
- * @param options Options object
- */
- add(
- message: React.ReactNode,
- type?: Indicator['type'],
- options?: Indicator['options']
- ): Indicator;
- addError(message?: string): Indicator;
- /**
- * Alias for add()
- */
- addMessage(
- message: React.ReactNode,
- type: Indicator['type'],
- options?: Indicator['options']
- ): Indicator;
- addSuccess(message: string): Indicator;
- /**
- * Appends a message to be displayed in list of indicators
- *
- * @param message Toast message to be displayed
- * @param type One of ['error', 'success', '']
- * @param options Options object
- */
- append(
- message: React.ReactNode,
- type: Indicator['type'],
- options?: Indicator['options']
- ): Indicator;
- /**
- * Remove all current indicators.
- */
- clear(): void;
- init(): void;
- /**
- * Remove an indicator
- */
- remove(indicator: Indicator): void;
-}
-
-const storeConfig: IndicatorStoreDefinition = {
- state: [],
- lastId: 0,
-
- init() {
- // XXX: Do not use `this.listenTo` in this store. We avoid usage of reflux
- // listeners due to their leaky nature in tests.
-
- this.state = [];
- this.lastId = 0;
- },
-
- addSuccess(message) {
- return this.add(message, 'success', {duration: 2000});
- },
-
- addError(message = t('An error occurred')) {
- return this.add(message, 'error', {duration: 2000});
- },
-
- addMessage(message, type, {append, ...options} = {}) {
- const indicator: Indicator = {
- id: this.lastId++,
- message,
- type,
- options,
- clearId: null,
- };
-
- if (options.duration) {
- indicator.clearId = window.setTimeout(() => {
- this.remove(indicator);
- }, options.duration);
- }
-
- const newItems = append ? [...this.state, indicator] : [indicator];
-
- this.state = newItems;
- this.trigger(this.state);
- return indicator;
- },
-
- append(message, type, options) {
- return this.addMessage(message, type, {
- ...options,
- append: true,
- });
- },
-
- add(message, type = 'loading', options = {}) {
- return this.addMessage(message, type, {
- ...options,
- append: false,
- });
- },
-
- clear() {
- this.state = [];
- this.trigger(this.state);
- },
-
- remove(indicator) {
- if (!indicator) {
- return;
- }
-
- this.state = this.state.filter(item => item !== indicator);
-
- if (indicator.clearId) {
- window.clearTimeout(indicator.clearId);
- indicator.clearId = null;
- }
-
- this.trigger(this.state);
- },
-
- getState() {
- return this.state;
- },
-};
-
-export const IndicatorStore = createStore(storeConfig);
diff --git a/static/app/types/system.tsx b/static/app/types/system.tsx
index 926942d89bb1..ba22030c7048 100644
--- a/static/app/types/system.tsx
+++ b/static/app/types/system.tsx
@@ -6,7 +6,6 @@ import type {ParntershipAgreementType} from './overrides';
import type {User} from './user';
export enum SentryInitRenderReactComponent {
- INDICATORS = 'Indicators',
SETUP_WIZARD = 'SetupWizard',
WEB_AUTHN_ASSSERT = 'WebAuthnAssert',
SU_STAFF_ACCESS_FORM = 'SuperuserStaffAccessForm',
diff --git a/static/app/views/app/index.tsx b/static/app/views/app/index.tsx
index 71b8dfc4bc4f..f7a24085a99c 100644
--- a/static/app/views/app/index.tsx
+++ b/static/app/views/app/index.tsx
@@ -11,7 +11,6 @@ import {
import {fetchGuides} from 'sentry/actionCreators/guides';
import {fetchOrganizations} from 'sentry/actionCreators/organizations';
import {ErrorBoundary} from 'sentry/components/errorBoundary';
-import Indicators from 'sentry/components/indicators';
import {Override} from 'sentry/components/override';
import {getOverride} from 'sentry/overrideRegistry';
import {ConfigStore} from 'sentry/stores/configStore';
@@ -221,7 +220,6 @@ export function App() {
-
{renderBody()}
diff --git a/static/app/views/detectors/detectorNewSettings.spec.tsx b/static/app/views/detectors/detectorNewSettings.spec.tsx
index 19477de7b5d9..3bf418d2a45f 100644
--- a/static/app/views/detectors/detectorNewSettings.spec.tsx
+++ b/static/app/views/detectors/detectorNewSettings.spec.tsx
@@ -16,7 +16,6 @@ import {
} from 'sentry-test/reactTestingLibrary';
import {selectEvent} from 'sentry-test/selectEvent';
-import * as indicators from 'sentry/actionCreators/indicator';
import {OrganizationStore} from 'sentry/stores/organizationStore';
import {ProjectsStore} from 'sentry/stores/projectsStore';
import {getDatasetConfig} from 'sentry/views/detectors/datasetConfig/getDatasetConfig';
@@ -1165,13 +1164,13 @@ describe('DetectorEdit', () => {
});
it('displays slug errors on the name field and in a toast', async () => {
- const mockAddErrorMessage = jest.spyOn(indicators, 'addErrorMessage');
+ const errorMessage = 'The slug "new-test-cron-job" is already in use.';
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/projects/${project.id}/detectors/`,
method: 'POST',
statusCode: 400,
body: {
- dataSources: {slug: ['The slug "new-test-cron-job" is already in use.']},
+ dataSources: {slug: [errorMessage]},
},
});
@@ -1186,27 +1185,24 @@ describe('DetectorEdit', () => {
await userEvent.click(screen.getByRole('button', {name: 'Create Monitor'}));
- await waitFor(() => {
- expect(mockAddErrorMessage).toHaveBeenCalledWith(
- 'The slug "new-test-cron-job" is already in use.'
- );
- });
-
- // The slug error is mapped to the name field and shown inline
+ // The slug error is mapped to the name field and also shown in a toast.
+ expect(await screen.findAllByText(errorMessage)).toHaveLength(2);
expect(
- await screen.findByText('The slug "new-test-cron-job" is already in use.')
+ within(screen.getByRole('region', {name: /Notifications/})).getByText(
+ errorMessage
+ )
).toBeInTheDocument();
});
it('displays schedule config errors on the schedule field and in a toast', async () => {
- const mockAddErrorMessage = jest.spyOn(indicators, 'addErrorMessage');
+ const errorMessage = 'Invalid schedule for schedule unit count';
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/projects/${project.id}/detectors/`,
method: 'POST',
statusCode: 400,
body: {
dataSources: {
- config: {schedule: ['Invalid schedule for schedule unit count']},
+ config: {schedule: [errorMessage]},
},
},
});
@@ -1218,14 +1214,11 @@ describe('DetectorEdit', () => {
await userEvent.click(await screen.findByRole('button', {name: 'Create Monitor'}));
- await waitFor(() => {
- expect(mockAddErrorMessage).toHaveBeenCalledWith(
- 'Invalid schedule for schedule unit count'
- );
- });
-
+ expect(await screen.findAllByText(errorMessage)).toHaveLength(2);
expect(
- await screen.findByText('Invalid schedule for schedule unit count')
+ within(screen.getByRole('region', {name: /Notifications/})).getByText(
+ errorMessage
+ )
).toBeInTheDocument();
});
});
diff --git a/static/app/views/issueDetails/foldSection.spec.tsx b/static/app/views/issueDetails/foldSection.spec.tsx
index 180eb5489945..d17f2de1200f 100644
--- a/static/app/views/issueDetails/foldSection.spec.tsx
+++ b/static/app/views/issueDetails/foldSection.spec.tsx
@@ -91,7 +91,7 @@ describe('FoldSection', () => {
}
);
- expect(screen.getByRole('region')).toHaveAccessibleName('Accessible Title');
+ expect(screen.getByRole('region', {name: 'Accessible Title'})).toBeInTheDocument();
expect(screen.getByRole('button')).toHaveAccessibleName(
'Collapse Accessible Title Section'
);
diff --git a/static/app/views/issueList/overview.actions.spec.tsx b/static/app/views/issueList/overview.actions.spec.tsx
index 4458937b2572..0e2b91152c3c 100644
--- a/static/app/views/issueList/overview.actions.spec.tsx
+++ b/static/app/views/issueList/overview.actions.spec.tsx
@@ -1,4 +1,3 @@
-import {Fragment} from 'react';
import Cookies from 'js-cookie';
import {GroupFixture} from 'sentry-fixture/group';
import {GroupStatsFixture} from 'sentry-fixture/groupStats';
@@ -13,7 +12,6 @@ import {
within,
} from 'sentry-test/reactTestingLibrary';
-import Indicators from 'sentry/components/indicators';
import {GroupStore} from 'sentry/stores/groupStore';
import {IssueListCacheStore} from 'sentry/stores/IssueListCacheStore';
import {TagStore} from 'sentry/stores/tagStore';
@@ -223,13 +221,7 @@ describe('IssueListOverview (actions)', () => {
method: 'PUT',
});
- render(
-
-
-
- ,
- {organization}
- );
+ render(, {organization});
expect(await screen.findByText('Group 1')).toBeInTheDocument();
const groups = screen.getAllByTestId('group');
diff --git a/static/app/views/issueList/pages/inbox/issuePreview/issuePreview.spec.tsx b/static/app/views/issueList/pages/inbox/issuePreview/issuePreview.spec.tsx
index 6e2905984bfa..e303e15d7208 100644
--- a/static/app/views/issueList/pages/inbox/issuePreview/issuePreview.spec.tsx
+++ b/static/app/views/issueList/pages/inbox/issuePreview/issuePreview.spec.tsx
@@ -1,4 +1,3 @@
-import {Fragment} from 'react';
import {
ExplorerAutofixBlockFixture,
ExplorerAutofixResponseFixture,
@@ -12,7 +11,6 @@ import {PullRequestFixture} from 'sentry-fixture/pullRequest';
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
import {clearIndicators} from 'sentry/actionCreators/indicator';
-import Indicators from 'sentry/components/indicators';
import {ProjectsStore} from 'sentry/stores/projectsStore';
import {GroupStatus, ProgressState, type Group} from 'sentry/types/group';
@@ -381,13 +379,7 @@ describe('IssuePreview', () => {
statusCode: 500,
});
- render(
-
-
-
- ,
- {organization}
- );
+ render(, {organization});
await userEvent.click(await screen.findByRole('button', {name: 'Resolve'}));
diff --git a/static/app/views/setupWizard/index.tsx b/static/app/views/setupWizard/index.tsx
index 6d2ea42f0f9f..c09731d666e3 100644
--- a/static/app/views/setupWizard/index.tsx
+++ b/static/app/views/setupWizard/index.tsx
@@ -1,6 +1,3 @@
-import {Fragment} from 'react';
-
-import Indicators from 'sentry/components/indicators';
import {LoadingError} from 'sentry/components/loadingError';
import {LoadingIndicator} from 'sentry/components/loadingIndicator';
import {t} from 'sentry/locale';
@@ -27,15 +24,10 @@ function SetupWizard({hash, enableProjectSelection = false}: Props) {
return ;
}
- return (
-
-
- {enableProjectSelection ? (
-
- ) : (
-
- )}
-
+ return enableProjectSelection ? (
+
+ ) : (
+
);
}
diff --git a/static/gsAdmin/views/layout.tsx b/static/gsAdmin/views/layout.tsx
index 45b489f7bc71..85c6434dea7a 100644
--- a/static/gsAdmin/views/layout.tsx
+++ b/static/gsAdmin/views/layout.tsx
@@ -9,7 +9,6 @@ import {Container, Flex, Stack} from '@sentry/scraps/layout';
import {Link} from '@sentry/scraps/link';
import {GlobalModal} from '@sentry/scraps/modal';
-import Indicators from 'sentry/components/indicators';
import {ListLink} from 'sentry/components/links/listLink';
import {IconChevron, IconMenu, IconSentry, IconSliders} from 'sentry/icons';
import {ScrapsProviders} from 'sentry/scrapsProviders';
@@ -94,7 +93,6 @@ export function Layout() {
-
{/* Mobile: tap-outside backdrop for the drawer */}
diff --git a/static/gsApp/components/startTrialButton.tsx b/static/gsApp/components/startTrialButton.tsx
index 553cbc735ba5..a1676a14bc12 100644
--- a/static/gsApp/components/startTrialButton.tsx
+++ b/static/gsApp/components/startTrialButton.tsx
@@ -4,9 +4,9 @@ import {
type ButtonProps,
type LinkButtonProps,
} from '@sentry/scraps/button';
+import {toast} from '@sentry/scraps/toast';
import {t} from 'sentry/locale';
-import {IndicatorStore} from 'sentry/stores/indicatorStore';
import type {Organization} from 'sentry/types/organization';
import TrialStarter from 'getsentry/components/trialStarter';
@@ -38,7 +38,7 @@ export function StartTrialButton({
source={source}
organization={organization}
onTrialFailed={() => {
- IndicatorStore.addError(t('Error starting trial. Please try again.'));
+ toast.error(t('Error starting trial. Please try again.'), {duration: 2000});
onTrialFailed?.();
}}
onTrialStarted={onTrialStarted}
diff --git a/tests/acceptance/test_api.py b/tests/acceptance/test_api.py
index 62c8004ef648..6b2c7c222a8d 100644
--- a/tests/acceptance/test_api.py
+++ b/tests/acceptance/test_api.py
@@ -25,8 +25,9 @@ def test_simple(self) -> None:
self.browser.click('[href="/settings/account/api/applications/"]')
self.browser.wait_until_not('[data-test-id="loading-indicator"]')
- self.browser.click_when_visible('[data-test-id="toast-success"]')
- self.browser.wait_until_not('[data-test-id="toast-success"]')
+ toast_selector = '[role="status"]'
+ self.browser.click_when_visible(f'{toast_selector} [aria-label="Dismiss"]')
+ self.browser.wait_until_not(toast_selector)
app = ApiApplication.objects.first()
assert app
@@ -36,5 +37,5 @@ def test_simple(self) -> None:
self.browser.click_when_visible('[aria-label="Remove"]')
self.browser.element("input[name='confirm-text']").send_keys(app.name)
self.browser.click_when_visible('[aria-label="Confirm"]')
- self.browser.wait_until_not('[data-test-id="toast-loading"]')
+ self.browser.wait_until_not(toast_selector)
self.browser.wait_until_test_id("empty-message")
diff --git a/tests/acceptance/test_organization_developer_settings.py b/tests/acceptance/test_organization_developer_settings.py
index f4b9f1da0e8d..9974bc7e6c95 100644
--- a/tests/acceptance/test_organization_developer_settings.py
+++ b/tests/acceptance/test_organization_developer_settings.py
@@ -92,7 +92,7 @@ def test_edit_integration_schema(self) -> None:
self.browser.click('[aria-label="Save Changes"]')
- self.browser.wait_until(".ref-success")
+ self.browser.wait_until('[role="status"]')
self.browser.wait_until('[data-test-id="tesla-app"]')
@@ -113,7 +113,7 @@ def test_remove_tokens_internal_app(self) -> None:
self.browser.click('[aria-label="Revoke"]')
self.browser.click('[data-test-id="confirm-button"]')
- self.browser.wait_until(".ref-success")
+ self.browser.wait_until('[role="status"]')
assert self.browser.find_element(
by=By.XPATH,
@@ -129,6 +129,6 @@ def test_add_tokens_internal_app(self) -> None:
assert self.browser.element_exists('[aria-label="Generated token"]') is False
self.browser.click('[data-test-id="token-add"]')
- self.browser.wait_until(".ref-success")
+ self.browser.wait_until('[role="status"]')
assert len(self.browser.elements('[aria-label="Generated token"]')) == 1
diff --git a/tests/acceptance/test_organization_sentry_app_detailed_view.py b/tests/acceptance/test_organization_sentry_app_detailed_view.py
index 245ed60cee89..c9c2099fc8c0 100644
--- a/tests/acceptance/test_organization_sentry_app_detailed_view.py
+++ b/tests/acceptance/test_organization_sentry_app_detailed_view.py
@@ -50,7 +50,7 @@ def test_add_sentry_app(self) -> None:
detail_view_page = OrganizationSentryAppDetailViewPage(browser=self.browser)
detail_view_page.click_install_button()
- self.browser.wait_until('[data-test-id="toast-success"]')
+ self.browser.wait_until('[role="status"]')
assert SentryAppInstallation.objects.filter(
organization_id=self.organization.id, sentry_app=self.sentry_app
)
@@ -68,7 +68,7 @@ def test_uninstallation(self) -> None:
detail_view_page = OrganizationSentryAppDetailViewPage(browser=self.browser)
detail_view_page.uninstall()
- self.browser.wait_until('[data-test-id="toast-success"]')
+ self.browser.wait_until('[role="status"]')
with self.tasks():
run_scheduled_deletions_control()
diff --git a/tests/js/sentry-test/reactTestingLibrary.tsx b/tests/js/sentry-test/reactTestingLibrary.tsx
index 36392a164441..a9a818d1db55 100644
--- a/tests/js/sentry-test/reactTestingLibrary.tsx
+++ b/tests/js/sentry-test/reactTestingLibrary.tsx
@@ -165,11 +165,11 @@ function makeAllTheProviders(options: ProviderOptions) {
-
-
- {wrappedContent}
-
-
+
+
+ {wrappedContent}
+
+
diff --git a/tests/js/sentry-test/scrapsTestingProviders.tsx b/tests/js/sentry-test/scrapsTestingProviders.tsx
index b63f4687727f..f1d587197dd0 100644
--- a/tests/js/sentry-test/scrapsTestingProviders.tsx
+++ b/tests/js/sentry-test/scrapsTestingProviders.tsx
@@ -1,3 +1,6 @@
+import {createPortal} from 'react-dom';
+
+import {ToastProvider} from '@sentry/scraps/toast';
import {
TranslationContextProvider,
type TranslationContextValue,
@@ -16,7 +19,10 @@ export function ScrapsTestingProviders({children}: {children: React.ReactNode})
return (
- {children}
+
+ {children}
+ {createPortal(, document.body)}
+
);
diff --git a/tests/js/setup.ts b/tests/js/setup.ts
index 3ceb48b5a5a4..433460584d40 100644
--- a/tests/js/setup.ts
+++ b/tests/js/setup.ts
@@ -214,7 +214,12 @@ jest.mock('sentry/utils/testableWindowLocation', () => ({
// Close any open modals before each test
beforeEach(closeModal);
-afterEach(resetResizeObservers);
+afterEach(() => {
+ const {toast} =
+ jest.requireActual('@sentry/scraps/toast');
+ toast.dismiss();
+ resetResizeObservers();
+});
jest.mock('echarts-for-react/lib/core', function echartsMockFactory() {
// We need to do this because `jest.mock` gets hoisted before imports and `React` is not
@@ -383,6 +388,8 @@ window.IntersectionObserver = class IntersectionObserver {
disconnect() {}
};
+HTMLElement.prototype.setPointerCapture ??= jest.fn();
+
window.ResizeObserver = MockResizeObserver;
// Mock the crypto.subtle API for Gravatar