Refactor SnackBar duration handling and progress bar visibility logic (#19712)

## Summary

Fixes error snackbars disappearing too quickly by **disabling
auto-dismiss for error variants by default**. Error snackbars now remain
visible until the user closes them.

Closes #19694

## What changed

- Error snackbars no longer default to a 6s timeout (they only
auto-dismiss if an explicit `duration` is provided).
- Non-error snackbars keep the existing default auto-dismiss behavior
(6s).
- Progress bar animation/visibility is tied to auto-dismiss (no progress
bar when there’s no duration).

**Files**
-
packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx

## How to test

1. Trigger a long error snackbar (example: spreadsheet/CSV import error
with a long message).
2. Confirm the snackbar **stays visible** until clicking **Close**.
3. Trigger a success/info snackbar and confirm it **still
auto-dismisses** after ~6s.

## Notes

- Call sites that explicitly pass `options.duration` for error snackbars
will continue to auto-dismiss (intentional).

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Aryan Ghugare
2026-04-15 21:52:28 +05:30
committed by GitHub
parent 56d6e13b5d
commit b9605a3003
4 changed files with 93 additions and 51 deletions
@@ -9,9 +9,9 @@ import {
useContext,
useMemo,
} from 'react';
import { Link } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import {
HorizontalSeparator,
IconAlertTriangle,
IconInfoCircle,
IconSquareRoundedCheck,
@@ -19,6 +19,7 @@ import {
} from 'twenty-ui/display';
import { ProgressBar, useProgressAnimation } from 'twenty-ui/feedback';
import { LightButton, LightIconButton } from 'twenty-ui/input';
import { UndecoratedLink } from 'twenty-ui/navigation';
import {
MOBILE_VIEWPORT,
ThemeContext,
@@ -39,9 +40,9 @@ export type SnackBarProps = Pick<ComponentPropsWithoutRef<'div'>, 'id'> & {
duration?: number;
icon?: ReactNode;
message: string;
actionText?: string;
actionOnClick?: () => void;
actionTo?: string;
buttonLabel?: string;
buttonOnClick?: () => void;
buttonTo?: string;
detailedMessage?: string;
onCancel?: () => void;
onClose?: () => void;
@@ -56,9 +57,9 @@ const StyledContainer = styled.div`
border-radius: ${themeCssVariables.border.radius.md};
box-shadow: ${themeCssVariables.boxShadow.strong};
box-sizing: border-box;
cursor: pointer;
margin-top: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[2]}
${themeCssVariables.spacing[1]};
position: relative;
width: 296px;
@@ -70,12 +71,15 @@ const StyledContainer = styled.div`
const StyledProgressBarContainer = styled.div`
bottom: 0;
height: auto;
left: 0;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
& > [role='progressbar'] {
height: 100%;
}
`;
const StyledHeader = styled.div`
@@ -112,24 +116,15 @@ const StyledDescription = styled.div`
width: 200px;
`;
const StyledLinkContainer = styled.div`
> a {
color: ${themeCssVariables.font.color.tertiary};
display: block;
font-size: ${themeCssVariables.font.size.sm};
max-width: 200px;
overflow: hidden;
padding-left: ${themeCssVariables.spacing[6]};
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: ${themeCssVariables.font.color.secondary};
}
}
const StyledBottomActionContainer = styled.div`
margin-top: ${themeCssVariables.spacing[2]};
`;
const StyledActionButton = styled.div`
padding-left: ${themeCssVariables.spacing[6]};
const StyledBottomAction = styled.div`
align-items: center;
display: flex;
justify-content: flex-end;
padding-top: ${themeCssVariables.spacing[1]};
`;
const defaultAriaLabelByVariant: Record<
@@ -151,9 +146,9 @@ export const SnackBar = ({
id,
message,
detailedMessage,
actionText,
actionOnClick,
actionTo,
buttonLabel,
buttonOnClick,
buttonTo,
onCancel,
onClose,
role = 'status',
@@ -205,15 +200,11 @@ export const SnackBar = ({
}, [iconComponent, variant, i18n, theme.icon.size.md, theme.snackBar]);
const handleMouseEnter = () => {
if (progressAnimation?.state === 'running') {
progressAnimation.pause();
}
progressAnimation?.pause();
};
const handleMouseLeave = () => {
if (progressAnimation?.state === 'paused') {
progressAnimation.play();
}
progressAnimation?.play();
};
const sanitizedMessage = sanitizeMessageToRenderInSnackbar(message);
@@ -251,16 +242,21 @@ export const SnackBar = ({
{isDefined(sanitizedDetailedMessage) && (
<StyledDescription>{sanitizedDetailedMessage}</StyledDescription>
)}
{actionText && actionTo && (
<StyledLinkContainer>
<Link to={actionTo}>{actionText}</Link>
</StyledLinkContainer>
)}
{actionText && actionOnClick && !actionTo && (
<StyledActionButton>
<LightButton title={actionText} onClick={actionOnClick} />
</StyledActionButton>
)}
{isDefined(buttonLabel) &&
(isDefined(buttonOnClick) || isDefined(buttonTo)) && (
<StyledBottomActionContainer>
<HorizontalSeparator noMargin />
<StyledBottomAction>
{isDefined(buttonTo) ? (
<UndecoratedLink to={buttonTo}>
<LightButton title={buttonLabel} />
</UndecoratedLink>
) : (
<LightButton title={buttonLabel} onClick={buttonOnClick} />
)}
</StyledBottomAction>
</StyledBottomActionContainer>
)}
</StyledContainer>
);
};
@@ -58,9 +58,9 @@ export const SnackBarProvider = ({ children }: React.PropsWithChildren) => {
message,
detailedMessage,
variant,
actionText,
actionOnClick,
actionTo,
buttonLabel,
buttonOnClick,
buttonTo,
}) => (
<motion.div
key={id}
@@ -78,9 +78,9 @@ export const SnackBarProvider = ({ children }: React.PropsWithChildren) => {
message,
detailedMessage,
variant,
actionText,
actionOnClick,
actionTo,
buttonLabel,
buttonOnClick,
buttonTo,
}}
onClose={() => handleSnackBarClose(id)}
/>
@@ -41,6 +41,33 @@ export const Default: Story = {
},
};
export const WithBottomButton: Story = {
args: {
variant: SnackBarVariant.Error,
message: 'An error has occurred',
detailedMessage: 'Error during useFindManyRecord...',
buttonLabel: 'Open Record',
buttonOnClick: fn(),
},
decorators: [ComponentDecorator],
parameters: {
chromatic: { disableSnapshot: true },
},
};
export const SuccessWithButton: Story = {
args: {
variant: SnackBarVariant.Success,
message: 'Record created successfully',
buttonLabel: 'View Record',
buttonOnClick: fn(),
},
decorators: [ComponentDecorator],
parameters: {
chromatic: { disableSnapshot: true },
},
};
export const Catalog: CatalogStory<Story, typeof SnackBar> = {
args: {
onCancel: fn(),
@@ -63,3 +90,22 @@ export const Catalog: CatalogStory<Story, typeof SnackBar> = {
},
},
};
export const CatalogWithButton: CatalogStory<Story, typeof SnackBar> = {
args: {
buttonLabel: 'Open Record',
buttonOnClick: fn(),
},
decorators: [CatalogDecorator],
parameters: {
catalog: {
dimensions: [
{
name: 'variants',
values: Object.values(SnackBarVariant),
props: (variant: SnackBarVariant) => ({ variant }),
},
],
},
},
};
@@ -7,7 +7,7 @@ import { type SnackBarOptions } from '@/ui/feedback/snack-bar-manager/states/sna
export const buildErrorAction = (
apolloError?: ErrorLike,
): Pick<SnackBarOptions, 'actionText' | 'actionTo'> | null => {
): Pick<SnackBarOptions, 'buttonLabel' | 'buttonTo'> | null => {
if (!apolloError) {
return null;
}
@@ -16,8 +16,8 @@ export const buildErrorAction = (
if (isDefined(conflictingRecord)) {
return {
actionText: t`View existing record`,
actionTo: getAppPath(AppPath.RecordShowPage, {
buttonLabel: t`View existing record`,
buttonTo: getAppPath(AppPath.RecordShowPage, {
objectNameSingular: conflictingRecord.conflictingObjectNameSingular,
objectRecordId: conflictingRecord.conflictingRecordId,
}),