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:
+38
-42
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+6
-6
@@ -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)}
|
||||
/>
|
||||
|
||||
+46
@@ -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 }),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+3
-3
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user