diff --git a/webapp/channels/src/components/access_history_modal/__snapshots__/access_history_modal.test.tsx.snap b/webapp/channels/src/components/access_history_modal/__snapshots__/access_history_modal.test.tsx.snap deleted file mode 100644 index 254dcabb148..00000000000 --- a/webapp/channels/src/components/access_history_modal/__snapshots__/access_history_modal.test.tsx.snap +++ /dev/null @@ -1,154 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/AccessHistoryModal should match snapshot when audits exist 1`] = ` - - - - - - - - - - - - - -`; - -exports[`components/AccessHistoryModal should match snapshot when no audits exist 1`] = ` - - - - - - - - - - - - - -`; diff --git a/webapp/channels/src/components/access_history_modal/access_history_modal.scss b/webapp/channels/src/components/access_history_modal/access_history_modal.scss index 5ca57e645d9..fc710583823 100644 --- a/webapp/channels/src/components/access_history_modal/access_history_modal.scss +++ b/webapp/channels/src/components/access_history_modal/access_history_modal.scss @@ -1,8 +1,15 @@ .access-history-modal { - .table { - thead > tr > th, - tbody > tr > td { - border-color: rgba(var(--center-channel-color-rgb), 0.12); + min-width: 40vw; + .GenericModal__body { + padding: 24px 0 0; + } + + .access-history-modal__body { + .table { + thead > tr > th, + tbody > tr > td { + border-color: rgba(var(--center-channel-color-rgb), 0.12); + } } } } diff --git a/webapp/channels/src/components/access_history_modal/access_history_modal.test.tsx b/webapp/channels/src/components/access_history_modal/access_history_modal.test.tsx index f9ec3be1085..8ddf6234cae 100644 --- a/webapp/channels/src/components/access_history_modal/access_history_modal.test.tsx +++ b/webapp/channels/src/components/access_history_modal/access_history_modal.test.tsx @@ -1,15 +1,24 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {fireEvent, screen, render, waitForElementToBeRemoved, waitFor} from '@testing-library/react'; -import {shallow} from 'enzyme'; +import {fireEvent, screen, waitFor} from '@testing-library/react'; import React from 'react'; import AccessHistoryModal from 'components/access_history_modal/access_history_modal'; -import AuditTable from 'components/audit_table'; -import LoadingScreen from 'components/loading_screen'; -import {withIntl} from 'tests/helpers/intl-test-helper'; +import {renderWithContext} from 'tests/react_testing_utils'; + +jest.mock('components/audit_table', () => { + return jest.fn().mockImplementation(() => { + return
; + }); +}); + +jest.mock('components/loading_screen', () => { + return jest.fn().mockImplementation(() => { + return
; + }); +}); describe('components/AccessHistoryModal', () => { const baseProps = { @@ -21,43 +30,63 @@ describe('components/AccessHistoryModal', () => { currentUserId: '', }; - test('should match snapshot when no audits exist', () => { - const wrapper = shallow( - , - ); - expect(wrapper).toMatchSnapshot(); - expect(wrapper.find(LoadingScreen).exists()).toBe(true); - expect(wrapper.find(AuditTable).exists()).toBe(false); + test('should show loading screen when no audits exist', () => { + renderWithContext(); + + expect(screen.getByTestId('loading-screen')).toBeInTheDocument(); + expect(screen.queryByTestId('audit-table')).not.toBeInTheDocument(); }); - test('should match snapshot when audits exist', () => { - const wrapper = shallow( - , + test('should show audit table when audits exist', () => { + renderWithContext( + , ); - wrapper.setProps({userAudits: ['audit1', 'audit2']}); - expect(wrapper).toMatchSnapshot(); - expect(wrapper.find(LoadingScreen).exists()).toBe(false); - expect(wrapper.find(AuditTable).exists()).toBe(true); + expect(screen.queryByTestId('loading-screen')).not.toBeInTheDocument(); + expect(screen.getByTestId('audit-table')).toBeInTheDocument(); }); - test('should have called actions.getUserAudits only when first rendered', () => { + test('should call getUserAudits on mount', () => { const actions = { getUserAudits: jest.fn(), }; const props = {...baseProps, actions}; - const view = render(withIntl()); + renderWithContext(); expect(actions.getUserAudits).toHaveBeenCalledTimes(1); - const newProps = {...props, currentUserId: 'foo'}; - view.rerender(withIntl()); - expect(actions.getUserAudits).toHaveBeenCalledTimes(1); + expect(actions.getUserAudits).toHaveBeenCalledWith('', 0, 200); }); - test('should hide', async () => { - render(withIntl()); + test('should call getUserAudits again when currentUserId changes', () => { + const actions = { + getUserAudits: jest.fn(), + }; + const props = {...baseProps, actions}; + + const {rerender} = renderWithContext(); + expect(actions.getUserAudits).toHaveBeenCalledTimes(1); + + const newProps = {...props, currentUserId: 'foo'}; + rerender(); + expect(actions.getUserAudits).toHaveBeenCalledTimes(2); + expect(actions.getUserAudits).toHaveBeenCalledWith('foo', 0, 200); + }); + + test('should call onHide when modal is closed', async () => { + const onHide = jest.fn(); + renderWithContext( + , + ); + await waitFor(() => screen.getByText('Access History')); fireEvent.click(screen.getByLabelText('Close')); - await waitForElementToBeRemoved(() => screen.getByText('Access History')); + + expect(onHide).toHaveBeenCalledTimes(1); }); }); diff --git a/webapp/channels/src/components/access_history_modal/access_history_modal.tsx b/webapp/channels/src/components/access_history_modal/access_history_modal.tsx index 931fb98bc72..4f06c8cbedf 100644 --- a/webapp/channels/src/components/access_history_modal/access_history_modal.tsx +++ b/webapp/channels/src/components/access_history_modal/access_history_modal.tsx @@ -1,10 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useState} from 'react'; -import {Modal} from 'react-bootstrap'; +import React, {useEffect} from 'react'; import {FormattedMessage} from 'react-intl'; +import {GenericModal} from '@mattermost/components'; import type {Audit} from '@mattermost/types/audits'; import AuditTable from 'components/audit_table'; @@ -29,15 +29,9 @@ const AccessHistoryModal = ({ onHide, userAudits, }: Props) => { - const [show, setShow] = useState(true); - - const onCloseClick = useCallback(() => { - setShow(false); - }, []); - useEffect(() => { getUserAudits(currentUserId, 0, 200); - }, []); + }, [currentUserId, getUserAudits]); let content; if (userAudits.length === 0) { @@ -53,42 +47,27 @@ const AccessHistoryModal = ({ } return ( - + } + modalHeaderTextId='accessHistoryModalLabel' + show={true} + onHide={onHide} + modalLocation='top' + isStacked={true} + compassDesign={true} + ariaLabelledby='accessHistoryModalLabel' > - - - - - - +
{content} - - - - - +
+ ); }; diff --git a/webapp/channels/src/components/activity_log_modal/__snapshots__/activity_log_modal.test.tsx.snap b/webapp/channels/src/components/activity_log_modal/__snapshots__/activity_log_modal.test.tsx.snap deleted file mode 100644 index 9295ca93872..00000000000 --- a/webapp/channels/src/components/activity_log_modal/__snapshots__/activity_log_modal.test.tsx.snap +++ /dev/null @@ -1,165 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/ActivityLogModal should match snapshot 1`] = ` - - - - - - - -

- -

-
- - - - - -`; - -exports[`components/ActivityLogModal should match snapshot when submitRevoke is called 1`] = ` - - - - - - - -

- -

- -
- - - -
-`; diff --git a/webapp/channels/src/components/activity_log_modal/activity_log_modal.scss b/webapp/channels/src/components/activity_log_modal/activity_log_modal.scss new file mode 100644 index 00000000000..b9f53e4b045 --- /dev/null +++ b/webapp/channels/src/components/activity_log_modal/activity_log_modal.scss @@ -0,0 +1,6 @@ +.activity-log-modal { + min-width: 40vw; + .GenericModal__body { + padding: 24px 24px 0; + } +} diff --git a/webapp/channels/src/components/activity_log_modal/activity_log_modal.test.tsx b/webapp/channels/src/components/activity_log_modal/activity_log_modal.test.tsx index d84e1a84a1c..567561b9636 100644 --- a/webapp/channels/src/components/activity_log_modal/activity_log_modal.test.tsx +++ b/webapp/channels/src/components/activity_log_modal/activity_log_modal.test.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {shallow} from 'enzyme'; +import {fireEvent, screen, waitFor} from '@testing-library/react'; import React from 'react'; import type {MouseEvent} from 'react'; @@ -9,6 +9,22 @@ import {General} from 'mattermost-redux/constants'; import ActivityLogModal from 'components/activity_log_modal/activity_log_modal'; +import {renderWithContext} from 'tests/react_testing_utils'; + +jest.mock('components/activity_log_modal/components/activity_log', () => { + return jest.fn().mockImplementation(({submitRevoke, currentSession}) => { + return ( +
submitRevoke(currentSession.id, e as MouseEvent)} + > + {'Activity Log Item'} +
+ ); + }); +}); + describe('components/ActivityLogModal', () => { const baseProps = { sessions: [], @@ -21,14 +37,58 @@ describe('components/ActivityLogModal', () => { locale: General.DEFAULT_LOCALE, }; - test('should match snapshot', () => { - const wrapper = shallow( - , - ); - expect(wrapper).toMatchSnapshot(); + test('should render empty state when no sessions exist', () => { + renderWithContext(); + + expect(screen.getByText('Active Sessions')).toBeInTheDocument(); + expect(screen.queryByTestId('activity-log')).not.toBeInTheDocument(); }); - test('should match snapshot when submitRevoke is called', () => { + test('should render sessions when they exist', () => { + const sessions = [ + {id: 'session1', props: {type: 'Web'}}, + {id: 'session2', props: {type: 'Web'}}, + ] as any; + + renderWithContext( + , + ); + + expect(screen.getAllByTestId('activity-log')).toHaveLength(2); + }); + + test('should filter out UserAccessToken sessions', () => { + const sessions = [ + {id: 'session1', props: {type: 'Web'}}, + {id: 'session2', props: {type: 'UserAccessToken'}}, + ] as any; + + renderWithContext( + , + ); + + expect(screen.getAllByTestId('activity-log')).toHaveLength(1); + }); + + test('should call getSessions on mount', () => { + const actions = { + getSessions: jest.fn(), + revokeSession: jest.fn(), + }; + const props = {...baseProps, actions}; + + renderWithContext(); + expect(actions.getSessions).toHaveBeenCalledTimes(1); + expect(actions.getSessions).toHaveBeenCalledWith(''); + }); + + test('should call revokeSession when session is revoked', async () => { const revokeSession = jest.fn().mockImplementation( () => { return new Promise((resolve) => { @@ -36,43 +96,49 @@ describe('components/ActivityLogModal', () => { }); }, ); + const getSessions = jest.fn(); const actions = { - getSessions: jest.fn(), + getSessions, revokeSession, }; - const props = {...baseProps, actions}; - const wrapper = shallow( - , + const sessions = [ + {id: 'session1', props: {type: 'Web'}}, + ] as any; + + renderWithContext( + , ); - wrapper.instance().submitRevoke('altId', {preventDefault: jest.fn()} as unknown as MouseEvent); - expect(wrapper).toMatchSnapshot(); + fireEvent.click(screen.getByTestId('activity-log')); + expect(revokeSession).toHaveBeenCalledTimes(1); - expect(revokeSession).toHaveBeenCalledWith('', 'altId'); + expect(revokeSession).toHaveBeenCalledWith('user1', 'session1'); + + // Wait for the promise to resolve + await waitFor(() => { + expect(getSessions).toHaveBeenCalledTimes(2); // Once on mount, once after revoke + expect(getSessions).toHaveBeenLastCalledWith('user1'); + }); }); - test('should have called actions.getUserAudits when onShow is called', () => { - const actions = { - getSessions: jest.fn(), - revokeSession: jest.fn(), - }; - const props = {...baseProps, actions}; - const wrapper = shallow( - , + test('should call onHide when modal is closed', async () => { + const onHide = jest.fn(); + renderWithContext( + , ); - wrapper.instance().onShow(); - expect(actions.getSessions).toHaveBeenCalledTimes(2); - }); + await waitFor(() => screen.getByText('Active Sessions')); + fireEvent.click(screen.getByLabelText('Close')); - test('should match state when onHide is called', () => { - const wrapper = shallow( - , - ); - - wrapper.setState({show: true}); - wrapper.instance().onHide(); - expect(wrapper.state('show')).toEqual(false); + expect(onHide).toHaveBeenCalledTimes(1); }); }); diff --git a/webapp/channels/src/components/activity_log_modal/activity_log_modal.tsx b/webapp/channels/src/components/activity_log_modal/activity_log_modal.tsx index 89bf41a0e67..1eedbafb1cf 100644 --- a/webapp/channels/src/components/activity_log_modal/activity_log_modal.tsx +++ b/webapp/channels/src/components/activity_log_modal/activity_log_modal.tsx @@ -1,16 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; -import {Modal} from 'react-bootstrap'; +import React, {useCallback, useEffect, useMemo} from 'react'; import {FormattedMessage} from 'react-intl'; +import {GenericModal} from '@mattermost/components'; import type {Session} from '@mattermost/types/sessions'; import type {ActionResult} from 'mattermost-redux/types/actions'; import ActivityLog from 'components/activity_log_modal/components/activity_log'; +import './activity_log_modal.scss'; + export type Props = { /** @@ -47,49 +49,34 @@ export type Props = { }; } -type State = { - show: boolean; -} - -export default class ActivityLogModal extends React.PureComponent { - static propTypes = { - - }; - - constructor(props: Props) { - super(props); - - this.state = { - show: true, - }; - } - - submitRevoke = (altId: string, e: React.MouseEvent) => { +const ActivityLogModal = ({ + currentUserId, + sessions, + locale, + onHide, + actions: { + getSessions, + revokeSession, + }, +}: Props) => { + const submitRevoke = useCallback((altId: string, e: React.MouseEvent) => { e.preventDefault(); const modalContent = (e.target as Element)?.closest('.modal-content'); modalContent?.classList.add('animation--highlight'); setTimeout(() => { modalContent?.classList.remove('animation--highlight'); }, 1500); - this.props.actions.revokeSession(this.props.currentUserId, altId).then(() => { - this.props.actions.getSessions(this.props.currentUserId); + revokeSession(currentUserId, altId).then(() => { + getSessions(currentUserId); }); - }; + }, [currentUserId, revokeSession, getSessions]); - onShow = () => { - this.props.actions.getSessions(this.props.currentUserId); - }; + useEffect(() => { + getSessions(currentUserId); + }, [currentUserId, getSessions]); - onHide = () => { - this.setState({show: false}); - }; - - componentDidMount() { - this.onShow(); - } - - render() { - const activityList = this.props.sessions.reduce((array: JSX.Element[], currentSession, index) => { + const activityList = useMemo(() => { + return sessions.reduce((array: JSX.Element[], currentSession, index) => { if (currentSession.props.type === 'UserAccessToken') { return array; } @@ -98,59 +85,45 @@ export default class ActivityLogModal extends React.PureComponent , ); return array; }, []); + }, [sessions, locale, submitRevoke]); - const content = {activityList}; + const content =
{activityList}
; - return ( - - - - - - - -

- -

- {content} -
- - - -
- ); - } -} + return ( + + } + show={true} + onHide={onHide} + ariaLabelledby='activityLogModalLabel' + modalLocation='top' + isStacked={true} + compassDesign={true} + > +
+

+ +

+ {content} +
+
+ ); +}; + +export default React.memo(ActivityLogModal); diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_archive_tab.test.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_archive_tab.test.tsx index 51cb5290a48..5fde48f4df4 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_archive_tab.test.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_archive_tab.test.tsx @@ -197,4 +197,36 @@ describe('ChannelSettingsArchiveTab', () => { // Check that deleteChannel was called with the invalid channel ID expect(channelActions.deleteChannel).toHaveBeenCalledWith(invalidChannel.id); }); + + it('should handle backdrops correctly when confirmation modal is opened', async () => { + // Create a mock backdrop element to simulate the parent modal's backdrop + const mockBackdrop = document.createElement('div'); + mockBackdrop.className = 'modal-backdrop'; + document.body.appendChild(mockBackdrop); + + try { + renderWithContext(); + + // Click the archive button to open the confirmation modal + await userEvent.click(screen.getByText('Archive this channel')); + + // Check that the confirmation modal is shown + expect(screen.getByTestId('archiveChannelConfirmModal')).toBeInTheDocument(); + + // With the new approach, there should be two backdrops: + // 1. The parent modal's backdrop (with opacity 0) + // 2. The confirmation modal's backdrop + const backdrops = document.querySelectorAll('.modal-backdrop'); + expect(backdrops.length).toBe(2); + + // Check that one of the backdrops has opacity 0 + const hasInvisibleBackdrop = Array.from(backdrops).some( + (backdrop) => (backdrop as HTMLElement).style.opacity === '0', + ); + expect(hasInvisibleBackdrop).toBe(true); + } finally { + // Clean up - remove the mock backdrop + document.body.removeChild(mockBackdrop); + } + }); }); diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_archive_tab.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_archive_tab.tsx index 6ca639d7f84..9d03b610334 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_archive_tab.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_archive_tab.tsx @@ -97,6 +97,7 @@ function ChannelSettingsArchiveTab({ confirmButtonClass='btn btn-danger' modalClass='archiveChannelConfirmModal' focusOriginElement='channelSettingsArchiveChannelButton' + isStacked={true} /> )}
diff --git a/webapp/channels/src/components/confirm_modal.tsx b/webapp/channels/src/components/confirm_modal.tsx index be97dd909af..259d4a258d2 100644 --- a/webapp/channels/src/components/confirm_modal.tsx +++ b/webapp/channels/src/components/confirm_modal.tsx @@ -93,6 +93,12 @@ type Props = { */ focusOriginElement?: string; + /** + * Whether this modal is stacked on top of another modal. + * When true, the modal will not render its own backdrop and will + * adjust the z-index of the parent modal's backdrop. + */ + isStacked?: boolean; }; type State = { @@ -196,6 +202,7 @@ export default class ConfirmModal extends React.Component { ariaLabelledby='confirmModalLabel' compassDesign={true} modalHeaderText={this.props.title} + isStacked={this.props.isStacked} >
void; onHide?: () => void; modalHeaderText?: React.ReactNode; + modalHeaderTextId?: string; modalSubheaderText?: React.ReactNode; show?: boolean; handleCancel?: () => void; @@ -55,6 +57,13 @@ export type Props = { showCloseButton?: boolean; showHeader?: boolean; + /** + * Whether this modal is stacked on top of another modal. + * When true, the modal will not render its own backdrop and will + * adjust the z-index of the parent modal's backdrop. + */ + isStacked?: boolean; + /* * Controls the vertical location of the modal. * 'top' => margin-top: 5vh @@ -97,6 +106,7 @@ export const GenericModal: React.FC = ({ onEntered, onHide, modalHeaderText, + modalHeaderTextId, modalSubheaderText, handleCancel, handleConfirm, @@ -126,6 +136,7 @@ export const GenericModal: React.FC = ({ headerButton, dataTestId, delayFocusTrap, + isStacked = false, }) => { const intl = useIntl(); @@ -134,20 +145,27 @@ export const GenericModal: React.FC = ({ const [showState, setShowState] = useState(show); + const onHideCallback = useCallback(() => { + setShowState(false); + onHide?.(); + }, [onHide]); + // Use focus trap to keep focus within the modal when it's open useFocusTrap(showState, containerRef, { delayMs: delayFocusTrap ? 500 : undefined, }); + // Use stacked modal hook to manage backdrop and z-index + // Only pass isStacked=true when it's explicitly set to true + const { + shouldRenderBackdrop, + modalStyle, + } = useStackedModal(Boolean(isStacked), showState); + useEffect(() => { setShowState(show); }, [show]); - const onHideCallback = useCallback(() => { - setShowState(false); - onHide?.(); - }, [onHide]); - const handleCancelCallback = useCallback((event: React.MouseEvent) => { event.preventDefault(); if (autoCloseOnCancelButton) { @@ -231,7 +249,7 @@ export const GenericModal: React.FC = ({ // Build header text if provided. const headerText = modalHeaderText && (
-

+

{modalHeaderText}

{headerButton} @@ -277,12 +295,14 @@ export const GenericModal: React.FC = ({ enforceFocus={enforceFocus} onHide={onHideCallback} onExited={onExited} - backdrop={backdrop} + backdrop={shouldRenderBackdrop ? backdrop : false} + backdropStyle={isStacked ? {zIndex: 1051} : undefined} backdropClassName={backdropClassName} container={container} keyboard={keyboardEscape} onEntered={onEntered} data-testid={dataTestId} + style={modalStyle} >
{ + const {shouldRenderBackdrop, modalStyle} = useStackedModal(isStacked, isOpen); + + return ( +
+
{shouldRenderBackdrop.toString()}
+
{modalStyle.zIndex || 'none'}
+
Modal Content
+
+ ); +}; + +describe('useStackedModal', () => { + // Mock document.querySelectorAll for backdrop tests + let originalQuerySelectorAll: typeof document.querySelectorAll; + let mockBackdrop1: HTMLElement; + let mockBackdrop2: HTMLElement; + + beforeEach(() => { + // Save original implementation + originalQuerySelectorAll = document.querySelectorAll; + + // Create mock backdrop elements + mockBackdrop1 = document.createElement('div'); + mockBackdrop1.className = 'modal-backdrop'; + mockBackdrop1.style.zIndex = '1040'; // Bootstrap default + mockBackdrop1.style.opacity = '0.5'; // Bootstrap default + + mockBackdrop2 = document.createElement('div'); + mockBackdrop2.className = 'modal-backdrop'; + mockBackdrop2.style.zIndex = '1045'; // Higher z-index for the second backdrop + mockBackdrop2.style.opacity = '0.5'; // Bootstrap default + + document.querySelectorAll = jest.fn().mockImplementation((selector: string) => { + if (selector === '.modal-backdrop') { + return [mockBackdrop1, mockBackdrop2]; // Return multiple backdrops to simulate stacked modals + } + return []; + }); + }); + + afterEach(() => { + // Restore original implementation + document.querySelectorAll = originalQuerySelectorAll; + }); + + describe('Integration Tests', () => { + test('does not affect regular modals', () => { + const props = { + show: true, + onHide: jest.fn(), + modalHeaderText: 'Regular Modal', + children:
Regular Modal Content
, + }; + + render( + wrapIntl(), + ); + + // The modal should be in the document + expect(screen.getByText('Regular Modal')).toBeInTheDocument(); + expect(screen.getByText('Regular Modal Content')).toBeInTheDocument(); + + // Regular modals should have a backdrop + // We can't directly test the backdrop since it's controlled by react-bootstrap + // But we can verify the modal displayed correctly and has the expected aria attributes + expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true'); + }); + + test('stacked modals have shouldRenderBackdrop=true but pass backdrop=false to Modal', () => { + const props = { + show: true, + onHide: jest.fn(), + modalHeaderText: 'Stacked Modal', + isStacked: true, + children:
Stacked Modal Content
, + }; + + render( + wrapIntl(), + ); + + // The modal should be in the document + expect(screen.getByText('Stacked Modal')).toBeInTheDocument(); + expect(screen.getByText('Stacked Modal Content')).toBeInTheDocument(); + + // We can't directly test the backdrop since it's controlled by react-bootstrap + // But we can verify the modal displayed correctly and has the expected aria attributes + expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true'); + }); + + test('stacked modals do not render their own backdrop', () => { + // This test verifies that stacked modals don't render their own backdrop through GenericModal + const stackedProps = { + show: true, + onHide: jest.fn(), + modalHeaderText: 'Stacked Modal', + id: 'stackedModal', + isStacked: true, + children:
Stacked Modal Content
, + }; + + render( + wrapIntl(), + ); + + // The modal should be in the document + expect(screen.getByText('Stacked Modal')).toBeInTheDocument(); + expect(screen.getByText('Stacked Modal Content')).toBeInTheDocument(); + + // The modal should have aria-modal="true" + expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true'); + }); + }); + + describe('Direct Hook Tests - Basic Functionality', () => { + test('regular modals should render their own backdrop', () => { + render(); + + expect(screen.getByTestId('should-render-backdrop')).toHaveTextContent('true'); + expect(screen.getByTestId('modal-z-index')).toHaveTextContent('none'); + }); + + test('stacked modals should have increased z-index', () => { + render(); + + // Verify exact z-index calculation + const expectedZIndex = BASE_MODAL_Z_INDEX + Z_INDEX_INCREMENT; + expect(screen.getByTestId('modal-z-index')).toHaveTextContent(expectedZIndex.toString()); + }); + }); + + describe('Direct Hook Tests - Backdrop Manipulation', () => { + test('stacked modals should modify parent backdrop opacity', () => { + render(); + + // The hook should have modified the most recent backdrop (mockBackdrop2) + expect(mockBackdrop2.style.opacity).toBe('0'); + }); + + test('stacked modals should set transition property on parent backdrop', () => { + render(); + + // Verify the transition property is set correctly + expect(mockBackdrop2.style.transition).toBe('opacity 150ms ease-in-out'); + }); + + test('stacked modals should calculate backdrop z-index correctly', () => { + render(); + + // The hook should calculate the backdrop z-index as stackedModalZIndex - 1 + // Where stackedModalZIndex = BASE_MODAL_Z_INDEX + Z_INDEX_INCREMENT + const expectedBackdropZIndex = (BASE_MODAL_Z_INDEX + Z_INDEX_INCREMENT) - 1; + + // We can't directly test this since the hook doesn't expose the backdrop z-index, + // but we can verify the hook's behavior by checking the modalStyle z-index + // and inferring that the backdrop z-index would be one less + const modalZIndex = parseInt(screen.getByTestId('modal-z-index').textContent || '0', 10); + expect(modalZIndex - 1).toBe(expectedBackdropZIndex); + }); + + test('cleanup should restore original backdrop properties', () => { + const {unmount} = render(); + + // The hook should have modified the parent backdrop + expect(mockBackdrop2.style.opacity).toBe('0'); + + // Unmount to trigger cleanup + unmount(); + + // Original opacity should be restored + expect(mockBackdrop2.style.opacity).toBe('0.5'); + + // Transition property should still be set for smooth fade-in + expect(mockBackdrop2.style.transition).toBe('opacity 150ms ease-in-out'); + }); + }); +}); diff --git a/webapp/platform/components/src/hooks/useStackedModal.ts b/webapp/platform/components/src/hooks/useStackedModal.ts new file mode 100644 index 00000000000..2c710303d5f --- /dev/null +++ b/webapp/platform/components/src/hooks/useStackedModal.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useLayoutEffect, useMemo, useRef, useState} from 'react'; + +const BASE_MODAL_Z_INDEX = 1050; // Bootstrap default modal z-index +const BASE_BACKDROP_Z_INDEX = 1040; // Bootstrap default backdrop z-index +const Z_INDEX_INCREMENT = 10; // Increment for each stacked modal level + +// No options needed since delayMs is not used by any consumers + +type StackedModalResult = { + + /** + * Whether the modal should render its own backdrop + */ + shouldRenderBackdrop: boolean; + + /** + * Style object for the modal element + */ + modalStyle: React.CSSProperties; + + /** + * Reference to the parent modal element (if this is a stacked modal) + */ + parentModalRef: React.RefObject; +}; + +/** + * A hook that manages stacked modals, controlling backdrop visibility and z-index values. + * + * @param isStacked Whether this modal is stacked on top of another modal + * @param isOpen Whether the modal is currently open + * @returns An object with properties to control modal and backdrop rendering + */ +export function useStackedModal( + isStacked: boolean, + isOpen: boolean, +): StackedModalResult { + // State to track whether this modal should render its own backdrop + const [shouldRenderBackdrop, setShouldRenderBackdrop] = useState(!isStacked); + + // State to track z-index values + const [zIndexes, setZIndexes] = useState({ + modal: BASE_MODAL_Z_INDEX, + backdrop: BASE_BACKDROP_Z_INDEX, + }); + + // Ref to store the parent modal element + const parentModalRef = useRef(null); + + // Ref to store the original z-index of the parent modal's backdrop + const originalBackdropZIndexRef = useRef(null); + + // Ref to store the parent modal's backdrop element + const backdropRef = useRef(null); + + // Ref to store the original opacity of the parent modal's backdrop + const originalBackdropOpacityRef = useRef(null); + + useLayoutEffect(() => { + // If this is not a stacked modal or not open, do nothing + if (!isStacked || !isOpen) { + return; + } + + // No timeout needed since we're not using delay + + // Function to adjust the backdrop for stacked modals + const adjustBackdrop = () => { + // For stacked modals, we want to render our own backdrop + setShouldRenderBackdrop(true); + + // Calculate the z-index for the stacked modal + const stackedModalZIndex = BASE_MODAL_Z_INDEX + Z_INDEX_INCREMENT; + + // Update the z-index for this modal and its backdrop + // The backdrop should be above the parent modal (1050) but below the stacked modal + setZIndexes({ + modal: stackedModalZIndex, + backdrop: stackedModalZIndex - 1, // This is 1050 + 10 - 1 = 1059 + }); + + // Adjust the parent backdrop's opacity and z-index + if (typeof document !== 'undefined') { + // Find all existing backdrops in the DOM + const backdrops = document.querySelectorAll('.modal-backdrop'); + if (backdrops.length > 0) { + // Get the most recent backdrop (the one with the highest z-index) + // This should be the backdrop of the parent modal + const parentBackdrop = backdrops[backdrops.length - 1] as HTMLElement; + backdropRef.current = parentBackdrop; + originalBackdropZIndexRef.current = parentBackdrop.style.zIndex || String(BASE_BACKDROP_Z_INDEX); + originalBackdropOpacityRef.current = parentBackdrop.style.opacity || '0.5'; // Default Bootstrap backdrop opacity + + // Add a transition for smooth opacity change + parentBackdrop.style.transition = 'opacity 150ms ease-in-out'; + parentBackdrop.style.opacity = '0'; + } + } + }; + + // Adjust the backdrop immediately (no delay option) + adjustBackdrop(); + + // Cleanup function + // eslint-disable-next-line consistent-return + return () => { + // Restore original backdrop properties + if (backdropRef.current) { + if (originalBackdropZIndexRef.current) { + // Restore original z-index if it was stored + backdropRef.current.style.zIndex = originalBackdropZIndexRef.current; + } + + if (originalBackdropOpacityRef.current) { + // Restore original opacity if it was stored + // Keep the transition for a smooth fade-in + backdropRef.current.style.transition = 'opacity 150ms ease-in-out'; + backdropRef.current.style.opacity = originalBackdropOpacityRef.current; + } + + // Clear refs + backdropRef.current = null; + originalBackdropZIndexRef.current = null; + originalBackdropOpacityRef.current = null; + } + }; + }, [isOpen, isStacked]); + + const modalStyle = useMemo(() => { + return isStacked ? { + zIndex: zIndexes.modal, + } : {}; + }, [isStacked, zIndexes.modal]); + + return { + shouldRenderBackdrop, + modalStyle, + parentModalRef, + }; +} + +export default useStackedModal;