* feat: implement floating banner system - Add FloatingBanner component with gesture support and keyboard awareness - Implement BannerManager singleton for banner lifecycle management - Create floating banner screen with SafeAreaProvider integration - Add comprehensive banner configuration types and positioning - Update Banner component to use modern gesture handling - Enhance BannerItem with improved typography and spacing (40px height) - Add ConnectionBanner improvements with better sizing - Remove ConnectionBanner from channel list (moved to floating system) - Update screens constants (remove FLOATING_BANNER - handled as overlay) - Add i18n support for limited network connection message The system provides: - Auto-hide functionality with customizable duration - Position-aware rendering (top/bottom with keyboard adjustment) - Tablet-specific offset handling - Swipe-to-dismiss with configurable thresholds - Custom component support alongside default banner items - Comprehensive test coverage with device-specific scenarios * docs: add floating banner system documentation and cleanup - Add comprehensive floating-banner.md with architecture diagrams - Remove incompatible connection_banner/index.ts file - Update device.ts hooks for better keyboard handling - Simplify screens/index.tsx floating banner registration - Update test/setup.ts to remove deprecated keyboard mocks - Clean up keyboard height logic and ESLint issues The documentation covers: - System architecture and component relationships - API reference and usage patterns - Performance considerations and best practices - Integration points and troubleshooting guide - Comprehensive testing strategy All tests now pass with the updated setup. * fix issue with translation file * some self cleanup. * renamed index.tsx => Banner.tsx * creaete meaningful tests for Banner component and all the hooks. * fix tests * cleanup based on initial review by AI * dismissible was set to true, changing to what was configured. * making title and message optional * addressed some comments in PR * more fixes based on PR review. * added future enhancement * dismissOverlay will be awaited * delay dismissing overlay so we don't have to show a new one all the time * make the banner stackable * Fix issue with last banner dismissal delayed by 2s * update floating-banner test * clean-up based on review by @enahum * fix failing test * fix failiing tests * rename confusing var * fixed issue with swipe not working on android * fix issue w/ android not registering touch events behind the overlay * fix failing test * animate the banner moving up when bottom banner first appear. * removed unused functions and update tests * add useMemo and useCallback * update jsdoc to say dismissable is default true * fix failing test
693 lines
24 KiB
TypeScript
693 lines
24 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import React from 'react';
|
|
import {Navigation} from 'react-native-navigation';
|
|
|
|
import {Screens} from '@constants';
|
|
import {showOverlay, dismissOverlay} from '@screens/navigation';
|
|
|
|
import {BannerManager, testExports} from './banner_manager';
|
|
|
|
import type {FloatingBannerConfig} from '@components/floating_banner/types';
|
|
|
|
interface MockOverlayProps {
|
|
banners: FloatingBannerConfig[];
|
|
onDismiss: (id: string) => void;
|
|
}
|
|
|
|
jest.mock('react-native-navigation', () => ({
|
|
Navigation: {
|
|
updateProps: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
jest.mock('@screens/navigation', () => ({
|
|
showOverlay: jest.fn(),
|
|
dismissOverlay: jest.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
jest.mock('@utils/datetime', () => ({
|
|
toMilliseconds: jest.fn().mockImplementation(({seconds}) => seconds * 1000),
|
|
}));
|
|
|
|
const mockShowOverlay = jest.mocked(showOverlay);
|
|
const mockDismissOverlay = jest.mocked(dismissOverlay);
|
|
const mockUpdateProps = jest.mocked(Navigation.updateProps);
|
|
|
|
describe('BannerManager', () => {
|
|
let timeoutId = 0;
|
|
type TimeoutCallback = () => void;
|
|
const timeoutCallbacks = new Map<number, TimeoutCallback>();
|
|
|
|
const mockSetTimeout = jest.spyOn(global, 'setTimeout').mockImplementation((cb: TimeoutCallback) => {
|
|
timeoutId++;
|
|
timeoutCallbacks.set(timeoutId, cb);
|
|
return timeoutId as unknown as NodeJS.Timeout;
|
|
});
|
|
|
|
const mockClearTimeout = jest.spyOn(global, 'clearTimeout').mockImplementation((id: NodeJS.Timeout) => {
|
|
timeoutCallbacks.delete(id as unknown as number);
|
|
});
|
|
|
|
const runAllTimers = () => {
|
|
const callbacks = Array.from(timeoutCallbacks.values());
|
|
timeoutCallbacks.clear();
|
|
callbacks.forEach((cb) => cb());
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
timeoutCallbacks.clear();
|
|
timeoutId = 0;
|
|
BannerManager.cleanup();
|
|
});
|
|
|
|
afterEach(() => {
|
|
timeoutCallbacks.clear();
|
|
BannerManager.cleanup();
|
|
});
|
|
|
|
describe('singleton pattern', () => {
|
|
it('should export the same instance', () => {
|
|
expect(BannerManager).toBeDefined();
|
|
expect(BannerManager).toBe(BannerManager);
|
|
});
|
|
|
|
it('should allow creating new instances via testExports', () => {
|
|
const newInstance = new testExports.BannerManager();
|
|
expect(newInstance).toBeDefined();
|
|
expect(newInstance).not.toBe(BannerManager);
|
|
});
|
|
});
|
|
|
|
describe('showBanner', () => {
|
|
const mockFloatingBannerConfig: FloatingBannerConfig = {
|
|
id: 'test-banner',
|
|
title: 'Test Title',
|
|
message: 'Test Message',
|
|
type: 'info',
|
|
};
|
|
|
|
it('should show banner with correct overlay configuration', async () => {
|
|
BannerManager.showBanner(mockFloatingBannerConfig);
|
|
await Promise.resolve();
|
|
|
|
expect(mockShowOverlay).toHaveBeenCalledWith(
|
|
Screens.FLOATING_BANNER,
|
|
{
|
|
banners: [expect.objectContaining({
|
|
id: 'test-banner',
|
|
title: 'Test Title',
|
|
message: 'Test Message',
|
|
type: 'info',
|
|
})],
|
|
onDismiss: expect.any(Function),
|
|
},
|
|
{
|
|
overlay: {
|
|
interceptTouchOutside: false,
|
|
},
|
|
},
|
|
'floating-banner-overlay',
|
|
);
|
|
});
|
|
|
|
it('should show banner overlay when showing banner', async () => {
|
|
BannerManager.showBanner(mockFloatingBannerConfig);
|
|
await Promise.resolve();
|
|
|
|
expect(mockShowOverlay).toHaveBeenCalledWith(
|
|
'FloatingBanner',
|
|
expect.objectContaining({
|
|
banners: expect.arrayContaining([
|
|
expect.objectContaining({id: 'test-banner'}),
|
|
]),
|
|
}),
|
|
expect.any(Object),
|
|
'floating-banner-overlay',
|
|
);
|
|
});
|
|
|
|
it('should show banner without title and message when using customComponent', async () => {
|
|
const bannerWithCustomComponent: FloatingBannerConfig = {
|
|
id: 'custom-banner',
|
|
customComponent: React.createElement('div', {testID: 'custom-content'}, 'Custom Content'),
|
|
};
|
|
|
|
BannerManager.showBanner(bannerWithCustomComponent);
|
|
await Promise.resolve();
|
|
|
|
expect(mockShowOverlay).toHaveBeenCalledWith(
|
|
Screens.FLOATING_BANNER,
|
|
expect.objectContaining({
|
|
banners: [expect.objectContaining({
|
|
id: 'custom-banner',
|
|
customComponent: expect.anything(),
|
|
})],
|
|
}),
|
|
expect.anything(),
|
|
'floating-banner-overlay',
|
|
);
|
|
});
|
|
|
|
it('should stack multiple banners and update overlay', async () => {
|
|
const firstBanner: FloatingBannerConfig = {
|
|
id: 'first-banner',
|
|
title: 'First',
|
|
message: 'First message',
|
|
};
|
|
|
|
const secondBanner: FloatingBannerConfig = {
|
|
id: 'second-banner',
|
|
title: 'Second',
|
|
message: 'Second message',
|
|
};
|
|
|
|
BannerManager.showBanner(firstBanner);
|
|
await Promise.resolve();
|
|
expect(mockShowOverlay).toHaveBeenCalledTimes(1);
|
|
|
|
BannerManager.showBanner(secondBanner);
|
|
await Promise.resolve();
|
|
|
|
expect(mockUpdateProps).toHaveBeenCalledWith(
|
|
'floating-banner-overlay',
|
|
expect.objectContaining({
|
|
banners: expect.arrayContaining([
|
|
expect.objectContaining({id: 'first-banner'}),
|
|
expect.objectContaining({id: 'second-banner'}),
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should handle custom content with React elements', async () => {
|
|
const customElement = React.createElement('div', {}, 'Custom content');
|
|
const bannerWithCustomComponent: FloatingBannerConfig = {
|
|
...mockFloatingBannerConfig,
|
|
customComponent: customElement,
|
|
};
|
|
|
|
jest.spyOn(React, 'isValidElement').mockReturnValue(true);
|
|
jest.spyOn(React, 'cloneElement').mockReturnValue(customElement);
|
|
|
|
BannerManager.showBanner(bannerWithCustomComponent);
|
|
await Promise.resolve();
|
|
|
|
expect(React.cloneElement).toHaveBeenCalledWith(
|
|
customElement,
|
|
{
|
|
onDismiss: expect.any(Function),
|
|
dismissible: undefined,
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should call cloned onDismiss handler when custom component is dismissed', async () => {
|
|
const mockOnDismiss = jest.fn();
|
|
const customElement = React.createElement('div', {}, 'Custom content');
|
|
const bannerWithCustomComponent: FloatingBannerConfig = {
|
|
id: 'custom-component-banner',
|
|
customComponent: customElement,
|
|
dismissible: true,
|
|
onDismiss: mockOnDismiss,
|
|
};
|
|
|
|
jest.spyOn(React, 'isValidElement').mockReturnValue(true);
|
|
|
|
BannerManager.showBanner(bannerWithCustomComponent);
|
|
await Promise.resolve();
|
|
|
|
const cloneCall = (React.cloneElement as jest.Mock).mock.calls[0];
|
|
const clonedProps = cloneCall[1];
|
|
const clonedOnDismiss = clonedProps.onDismiss;
|
|
|
|
clonedOnDismiss();
|
|
await Promise.resolve();
|
|
|
|
expect(mockOnDismiss).toHaveBeenCalled();
|
|
|
|
runAllTimers();
|
|
await Promise.resolve();
|
|
runAllTimers();
|
|
await Promise.resolve();
|
|
|
|
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
});
|
|
|
|
it('should respect dismissible property from banner config', async () => {
|
|
const customElement = React.createElement('div', {}, 'Custom content');
|
|
|
|
const dismissibleBanner: FloatingBannerConfig = {
|
|
...mockFloatingBannerConfig,
|
|
customComponent: customElement,
|
|
dismissible: true,
|
|
};
|
|
|
|
const nonDismissibleBanner: FloatingBannerConfig = {
|
|
...mockFloatingBannerConfig,
|
|
customComponent: customElement,
|
|
dismissible: false,
|
|
};
|
|
|
|
jest.spyOn(React, 'isValidElement').mockReturnValue(true);
|
|
jest.spyOn(React, 'cloneElement').mockReturnValue(customElement);
|
|
|
|
BannerManager.showBanner(dismissibleBanner);
|
|
await Promise.resolve();
|
|
expect(React.cloneElement).toHaveBeenCalledWith(
|
|
customElement,
|
|
{
|
|
onDismiss: expect.any(Function),
|
|
dismissible: true,
|
|
},
|
|
);
|
|
|
|
jest.clearAllMocks();
|
|
|
|
BannerManager.showBanner(nonDismissibleBanner);
|
|
await Promise.resolve();
|
|
expect(React.cloneElement).toHaveBeenCalledWith(
|
|
customElement,
|
|
{
|
|
onDismiss: expect.any(Function),
|
|
dismissible: false,
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should call original onDismiss when banner is dismissed via handleDismiss', async () => {
|
|
const mockOnDismiss = jest.fn();
|
|
const bannerWithOnDismiss: FloatingBannerConfig = {
|
|
...mockFloatingBannerConfig,
|
|
onDismiss: mockOnDismiss,
|
|
};
|
|
|
|
BannerManager.showBanner(bannerWithOnDismiss);
|
|
await Promise.resolve();
|
|
|
|
const overlayCall = mockShowOverlay.mock.calls[0];
|
|
const overlayOnDismiss = (overlayCall?.[1] as MockOverlayProps)?.onDismiss;
|
|
|
|
overlayOnDismiss?.('test-banner');
|
|
await Promise.resolve();
|
|
|
|
expect(mockOnDismiss).toHaveBeenCalled();
|
|
|
|
runAllTimers();
|
|
await Promise.resolve();
|
|
|
|
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
});
|
|
|
|
it('should remove banner from list when dismissed but keep overlay for other banners', async () => {
|
|
const firstBanner: FloatingBannerConfig = {
|
|
id: 'first-banner',
|
|
title: 'First',
|
|
message: 'First message',
|
|
onDismiss: jest.fn(),
|
|
};
|
|
|
|
const secondBanner: FloatingBannerConfig = {
|
|
id: 'second-banner',
|
|
title: 'Second',
|
|
message: 'Second message',
|
|
};
|
|
|
|
BannerManager.showBanner(firstBanner);
|
|
await Promise.resolve();
|
|
BannerManager.showBanner(secondBanner);
|
|
await Promise.resolve();
|
|
|
|
const overlayCall = mockUpdateProps.mock.calls[0];
|
|
const overlayOnDismiss = (overlayCall?.[1] as MockOverlayProps)?.onDismiss;
|
|
|
|
overlayOnDismiss?.('first-banner');
|
|
await Promise.resolve();
|
|
|
|
expect(firstBanner.onDismiss).toHaveBeenCalled();
|
|
expect(mockUpdateProps).toHaveBeenCalledWith(
|
|
'floating-banner-overlay',
|
|
expect.objectContaining({
|
|
banners: expect.arrayContaining([
|
|
expect.objectContaining({id: 'second-banner'}),
|
|
]),
|
|
}),
|
|
);
|
|
expect(mockDismissOverlay).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not call onDismiss for different banner IDs in overlay callback', () => {
|
|
const mockOnDismiss = jest.fn();
|
|
const bannerWithOnDismiss: FloatingBannerConfig = {
|
|
...mockFloatingBannerConfig,
|
|
onDismiss: mockOnDismiss,
|
|
};
|
|
|
|
BannerManager.showBanner(bannerWithOnDismiss);
|
|
|
|
const overlayCall = mockShowOverlay.mock.calls[0];
|
|
const overlayOnDismiss = (overlayCall?.[1] as MockOverlayProps)?.onDismiss;
|
|
|
|
overlayOnDismiss?.('different-banner-id');
|
|
|
|
expect(mockOnDismiss).not.toHaveBeenCalled();
|
|
expect(mockDismissOverlay).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle errors in onDismiss callback gracefully', async () => {
|
|
const mockOnDismiss = jest.fn().mockImplementation(() => {
|
|
throw new Error('Test error');
|
|
});
|
|
const bannerWithErrorOnDismiss: FloatingBannerConfig = {
|
|
...mockFloatingBannerConfig,
|
|
onDismiss: mockOnDismiss,
|
|
};
|
|
|
|
BannerManager.showBanner(bannerWithErrorOnDismiss);
|
|
await Promise.resolve();
|
|
|
|
const overlayCall = mockShowOverlay.mock.calls[0];
|
|
const overlayOnDismiss = (overlayCall?.[1] as MockOverlayProps)?.onDismiss;
|
|
|
|
expect(() => overlayOnDismiss?.('test-banner')).not.toThrow();
|
|
await Promise.resolve();
|
|
|
|
runAllTimers();
|
|
await Promise.resolve();
|
|
|
|
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
});
|
|
});
|
|
|
|
describe('showBannerWithAutoHide', () => {
|
|
const mockFloatingBannerConfig: FloatingBannerConfig = {
|
|
id: 'auto-hide-banner',
|
|
title: 'Auto Hide Banner',
|
|
message: 'This will auto hide',
|
|
};
|
|
|
|
it('should show banner and set timeout for auto hide with default duration', async () => {
|
|
BannerManager.showBannerWithAutoHide(mockFloatingBannerConfig);
|
|
await Promise.resolve();
|
|
|
|
expect(mockShowOverlay).toHaveBeenCalled();
|
|
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 5000);
|
|
});
|
|
|
|
it('should show banner and set timeout for auto hide with custom duration', async () => {
|
|
const customDuration = 3000;
|
|
|
|
BannerManager.showBannerWithAutoHide(mockFloatingBannerConfig, customDuration);
|
|
await Promise.resolve();
|
|
|
|
expect(mockShowOverlay).toHaveBeenCalled();
|
|
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), customDuration);
|
|
});
|
|
|
|
it('should hide banner when timeout executes', async () => {
|
|
BannerManager.showBannerWithAutoHide(mockFloatingBannerConfig);
|
|
await Promise.resolve();
|
|
|
|
expect(mockShowOverlay).toHaveBeenCalled();
|
|
|
|
runAllTimers();
|
|
await Promise.resolve();
|
|
runAllTimers();
|
|
await Promise.resolve();
|
|
|
|
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
});
|
|
});
|
|
|
|
describe('hideBanner', () => {
|
|
const mockFloatingBannerConfig: FloatingBannerConfig = {
|
|
id: 'hide-test-banner',
|
|
title: 'Hide Test',
|
|
message: 'Test hiding',
|
|
};
|
|
|
|
it('should hide visible banner and clear timeouts', async () => {
|
|
BannerManager.showBanner(mockFloatingBannerConfig);
|
|
await Promise.resolve();
|
|
expect(mockShowOverlay).toHaveBeenCalled();
|
|
|
|
BannerManager.hideBanner('hide-test-banner');
|
|
await Promise.resolve();
|
|
|
|
runAllTimers();
|
|
await Promise.resolve();
|
|
runAllTimers();
|
|
await Promise.resolve();
|
|
|
|
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
});
|
|
|
|
it('should hide specific banner by ID', async () => {
|
|
const firstBanner: FloatingBannerConfig = {
|
|
id: 'first-banner',
|
|
title: 'First',
|
|
message: 'First message',
|
|
};
|
|
|
|
const secondBanner: FloatingBannerConfig = {
|
|
id: 'second-banner',
|
|
title: 'Second',
|
|
message: 'Second message',
|
|
};
|
|
|
|
BannerManager.showBanner(firstBanner);
|
|
BannerManager.showBanner(secondBanner);
|
|
await Promise.resolve();
|
|
|
|
BannerManager.hideBanner('first-banner');
|
|
await Promise.resolve();
|
|
|
|
expect(mockUpdateProps).toHaveBeenCalledWith(
|
|
'floating-banner-overlay',
|
|
expect.objectContaining({
|
|
banners: expect.arrayContaining([
|
|
expect.objectContaining({id: 'second-banner'}),
|
|
]),
|
|
}),
|
|
);
|
|
expect(mockDismissOverlay).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should call current onDismiss callback when hiding', () => {
|
|
const mockOnDismiss = jest.fn();
|
|
const bannerWithOnDismiss: FloatingBannerConfig = {
|
|
...mockFloatingBannerConfig,
|
|
onDismiss: mockOnDismiss,
|
|
};
|
|
|
|
BannerManager.showBanner(bannerWithOnDismiss);
|
|
BannerManager.hideBanner('hide-test-banner');
|
|
|
|
expect(mockOnDismiss).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle errors in onDismiss callback during hide', async () => {
|
|
const mockOnDismiss = jest.fn().mockImplementation(() => {
|
|
throw new Error('Hide error');
|
|
});
|
|
const bannerWithErrorOnDismiss: FloatingBannerConfig = {
|
|
...mockFloatingBannerConfig,
|
|
onDismiss: mockOnDismiss,
|
|
};
|
|
|
|
BannerManager.showBanner(bannerWithErrorOnDismiss);
|
|
await Promise.resolve();
|
|
|
|
expect(() => BannerManager.hideBanner('hide-test-banner')).not.toThrow();
|
|
await Promise.resolve();
|
|
|
|
runAllTimers();
|
|
await Promise.resolve();
|
|
|
|
expect(mockOnDismiss).toHaveBeenCalled();
|
|
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
});
|
|
});
|
|
|
|
describe('hideAllBanners', () => {
|
|
it('should hide all banners at once', async () => {
|
|
const firstBanner: FloatingBannerConfig = {
|
|
id: 'first-banner',
|
|
title: 'First',
|
|
message: 'First message',
|
|
};
|
|
|
|
const secondBanner: FloatingBannerConfig = {
|
|
id: 'second-banner',
|
|
title: 'Second',
|
|
message: 'Second message',
|
|
};
|
|
|
|
const thirdBanner: FloatingBannerConfig = {
|
|
id: 'third-banner',
|
|
title: 'Third',
|
|
message: 'Third message',
|
|
};
|
|
|
|
BannerManager.showBanner(firstBanner);
|
|
BannerManager.showBanner(secondBanner);
|
|
BannerManager.showBanner(thirdBanner);
|
|
await Promise.resolve();
|
|
|
|
expect(mockShowOverlay).toHaveBeenCalled();
|
|
|
|
BannerManager.hideAllBanners();
|
|
await Promise.resolve();
|
|
|
|
runAllTimers();
|
|
await Promise.resolve();
|
|
|
|
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
});
|
|
|
|
it('should clear all auto-hide timers when hiding all banners', async () => {
|
|
const firstBanner: FloatingBannerConfig = {
|
|
id: 'first-auto-hide',
|
|
title: 'First Auto Hide',
|
|
message: 'First message',
|
|
};
|
|
|
|
const secondBanner: FloatingBannerConfig = {
|
|
id: 'second-auto-hide',
|
|
title: 'Second Auto Hide',
|
|
message: 'Second message',
|
|
};
|
|
|
|
BannerManager.showBannerWithAutoHide(firstBanner, 3000);
|
|
BannerManager.showBannerWithAutoHide(secondBanner, 5000);
|
|
await Promise.resolve();
|
|
|
|
expect(mockShowOverlay).toHaveBeenCalled();
|
|
|
|
const clearTimeoutCallsBefore = mockClearTimeout.mock.calls.length;
|
|
BannerManager.hideAllBanners();
|
|
await Promise.resolve();
|
|
|
|
expect(mockClearTimeout.mock.calls.length).toBeGreaterThan(clearTimeoutCallsBefore);
|
|
|
|
runAllTimers();
|
|
await Promise.resolve();
|
|
|
|
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
});
|
|
|
|
it('should do nothing when no banners are visible', () => {
|
|
BannerManager.hideAllBanners();
|
|
|
|
expect(mockDismissOverlay).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should set empty banners array when hiding last banner', async () => {
|
|
const banner: FloatingBannerConfig = {
|
|
id: 'single-banner',
|
|
title: 'Single',
|
|
message: 'Single banner',
|
|
};
|
|
|
|
BannerManager.showBanner(banner);
|
|
await Promise.resolve();
|
|
|
|
expect(mockShowOverlay).toHaveBeenCalled();
|
|
|
|
BannerManager.hideBanner('single-banner');
|
|
await Promise.resolve();
|
|
|
|
expect(mockUpdateProps).toHaveBeenCalledWith(
|
|
'floating-banner-overlay',
|
|
expect.objectContaining({
|
|
banners: [],
|
|
onDismiss: expect.any(Function),
|
|
}),
|
|
);
|
|
|
|
const updatePropsCall = mockUpdateProps.mock.calls[mockUpdateProps.mock.calls.length - 1];
|
|
const emptyOnDismiss = (updatePropsCall?.[1] as MockOverlayProps)?.onDismiss;
|
|
|
|
expect(() => emptyOnDismiss?.('non-existent-banner')).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('dismiss delay behavior', () => {
|
|
it('should cancel overlay dismiss when new banner is added during delay', async () => {
|
|
const firstBanner: FloatingBannerConfig = {
|
|
id: 'first-banner',
|
|
title: 'First',
|
|
message: 'First message',
|
|
};
|
|
|
|
const secondBanner: FloatingBannerConfig = {
|
|
id: 'second-banner',
|
|
title: 'Second',
|
|
message: 'Second message',
|
|
};
|
|
|
|
BannerManager.showBanner(firstBanner);
|
|
await Promise.resolve();
|
|
|
|
const dismissCallsBefore = mockDismissOverlay.mock.calls.length;
|
|
|
|
BannerManager.hideBanner('first-banner');
|
|
await Promise.resolve();
|
|
|
|
runAllTimers();
|
|
await Promise.resolve();
|
|
|
|
BannerManager.showBanner(secondBanner);
|
|
await Promise.resolve();
|
|
|
|
runAllTimers();
|
|
await Promise.resolve();
|
|
|
|
expect(mockDismissOverlay).toHaveBeenCalledTimes(dismissCallsBefore);
|
|
expect(mockShowOverlay).toHaveBeenLastCalledWith(
|
|
'FloatingBanner',
|
|
expect.objectContaining({
|
|
banners: expect.arrayContaining([
|
|
expect.objectContaining({id: 'second-banner'}),
|
|
]),
|
|
}),
|
|
expect.any(Object),
|
|
'floating-banner-overlay',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('cleanup', () => {
|
|
const mockFloatingBannerConfig: FloatingBannerConfig = {
|
|
id: 'cleanup-test-banner',
|
|
title: 'Cleanup Test',
|
|
message: 'Test cleanup',
|
|
};
|
|
|
|
it('should clear all timeouts and hide banner', async () => {
|
|
BannerManager.showBannerWithAutoHide(mockFloatingBannerConfig);
|
|
await Promise.resolve();
|
|
|
|
const clearTimeoutCallsBefore = mockClearTimeout.mock.calls.length;
|
|
BannerManager.cleanup();
|
|
|
|
expect(mockClearTimeout.mock.calls.length).toBeGreaterThan(clearTimeoutCallsBefore);
|
|
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
});
|
|
|
|
it('should reset manager state', async () => {
|
|
BannerManager.showBanner(mockFloatingBannerConfig);
|
|
await Promise.resolve();
|
|
|
|
expect(mockShowOverlay).toHaveBeenCalled();
|
|
|
|
BannerManager.cleanup();
|
|
|
|
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
});
|
|
});
|
|
});
|