Mm 64318 stacked modals backdrop fix (#31086)

* stacked modals backdrop fix

* migrate activity log and access history modals to ng stack and fix stacked backdrop

* update e2e test to stacked modals; added support for modal header id for accesibility

* apply pr feedback to improve performance

* fix overflow issue with generic modals

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Pablo Vélez
2025-07-14 11:31:22 +02:00
committed by GitHub
co-authored by Mattermost Build
parent a17c387ff2
commit 86cbe3cb18
20 changed files with 704 additions and 524 deletions
@@ -1,154 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/AccessHistoryModal should match snapshot when audits exist 1`] = `
<Modal
animation={true}
aria-labelledby="accessHistoryModalLabel"
autoFocus={true}
backdrop={true}
bsClass="modal"
bsSize="large"
dialogClassName="a11y__modal modal--scroll access-history-modal"
dialogComponentClass={[Function]}
enforceFocus={true}
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="none"
show={true}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="accessHistoryModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="Access History"
id="access_history.title"
/>
</ModalTitle>
</ModalHeader>
<ModalBody
bsClass="modal-body"
componentClass="div"
>
<Connect(injectIntl(AuditTable))
audits={
Array [
"audit1",
"audit2",
]
}
showIp={true}
showSession={true}
/>
</ModalBody>
<ModalFooter
bsClass="modal-footer"
className="modal-footer--invisible"
componentClass="div"
>
<button
className="btn btn-tertiary"
id="closeModalButton"
type="button"
>
<MemoizedFormattedMessage
defaultMessage="Close"
id="general_button.close"
/>
</button>
</ModalFooter>
</Modal>
`;
exports[`components/AccessHistoryModal should match snapshot when no audits exist 1`] = `
<Modal
animation={true}
aria-labelledby="accessHistoryModalLabel"
autoFocus={true}
backdrop={true}
bsClass="modal"
bsSize="large"
dialogClassName="a11y__modal modal--scroll access-history-modal"
dialogComponentClass={[Function]}
enforceFocus={true}
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="none"
show={true}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="accessHistoryModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="Access History"
id="access_history.title"
/>
</ModalTitle>
</ModalHeader>
<ModalBody
bsClass="modal-body"
componentClass="div"
>
<LoadingScreen />
</ModalBody>
<ModalFooter
bsClass="modal-footer"
className="modal-footer--invisible"
componentClass="div"
>
<button
className="btn btn-tertiary"
id="closeModalButton"
type="button"
>
<MemoizedFormattedMessage
defaultMessage="Close"
id="general_button.close"
/>
</button>
</ModalFooter>
</Modal>
`;
@@ -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);
}
}
}
}
@@ -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 <div data-testid='audit-table'/>;
});
});
jest.mock('components/loading_screen', () => {
return jest.fn().mockImplementation(() => {
return <div data-testid='loading-screen'/>;
});
});
describe('components/AccessHistoryModal', () => {
const baseProps = {
@@ -21,43 +30,63 @@ describe('components/AccessHistoryModal', () => {
currentUserId: '',
};
test('should match snapshot when no audits exist', () => {
const wrapper = shallow(
<AccessHistoryModal {...baseProps}/>,
);
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(<AccessHistoryModal {...baseProps}/>);
expect(screen.getByTestId('loading-screen')).toBeInTheDocument();
expect(screen.queryByTestId('audit-table')).not.toBeInTheDocument();
});
test('should match snapshot when audits exist', () => {
const wrapper = shallow(
<AccessHistoryModal {...baseProps}/>,
test('should show audit table when audits exist', () => {
renderWithContext(
<AccessHistoryModal
{...baseProps}
userAudits={['audit1', 'audit2'] as any}
/>,
);
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(<AccessHistoryModal {...props}/>));
renderWithContext(<AccessHistoryModal {...props}/>);
expect(actions.getUserAudits).toHaveBeenCalledTimes(1);
const newProps = {...props, currentUserId: 'foo'};
view.rerender(withIntl(<AccessHistoryModal {...newProps}/>));
expect(actions.getUserAudits).toHaveBeenCalledTimes(1);
expect(actions.getUserAudits).toHaveBeenCalledWith('', 0, 200);
});
test('should hide', async () => {
render(withIntl(<AccessHistoryModal {...baseProps}/>));
test('should call getUserAudits again when currentUserId changes', () => {
const actions = {
getUserAudits: jest.fn(),
};
const props = {...baseProps, actions};
const {rerender} = renderWithContext(<AccessHistoryModal {...props}/>);
expect(actions.getUserAudits).toHaveBeenCalledTimes(1);
const newProps = {...props, currentUserId: 'foo'};
rerender(<AccessHistoryModal {...newProps}/>);
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(
<AccessHistoryModal
{...baseProps}
onHide={onHide}
/>,
);
await waitFor(() => screen.getByText('Access History'));
fireEvent.click(screen.getByLabelText('Close'));
await waitForElementToBeRemoved(() => screen.getByText('Access History'));
expect(onHide).toHaveBeenCalledTimes(1);
});
});
@@ -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 (
<Modal
dialogClassName='a11y__modal modal--scroll access-history-modal'
show={show}
onHide={onCloseClick}
onExited={onHide}
bsSize='large'
role='none'
aria-labelledby='accessHistoryModalLabel'
<GenericModal
id='accessHistoryModal'
className='a11y__modal access-history-modal modal--scroll'
modalHeaderText={
<FormattedMessage
id='access_history.title'
defaultMessage='Access History'
/>
}
modalHeaderTextId='accessHistoryModalLabel'
show={true}
onHide={onHide}
modalLocation='top'
isStacked={true}
compassDesign={true}
ariaLabelledby='accessHistoryModalLabel'
>
<Modal.Header closeButton={true}>
<Modal.Title
componentClass='h1'
id='accessHistoryModalLabel'
>
<FormattedMessage
id='access_history.title'
defaultMessage='Access History'
/>
</Modal.Title>
</Modal.Header>
<Modal.Body>
<div className='access-history-modal__body'>
{content}
</Modal.Body>
<Modal.Footer className='modal-footer--invisible'>
<button
id='closeModalButton'
type='button'
className='btn btn-tertiary'
>
<FormattedMessage
id='general_button.close'
defaultMessage='Close'
/>
</button>
</Modal.Footer>
</Modal>
</div>
</GenericModal>
);
};
@@ -1,165 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/ActivityLogModal should match snapshot 1`] = `
<Modal
animation={true}
aria-labelledby="activityLogModalLabel"
autoFocus={true}
backdrop={true}
bsClass="modal"
bsSize="large"
dialogClassName="a11y__modal modal--scroll"
dialogComponentClass={[Function]}
enforceFocus={true}
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="none"
show={true}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="activityLogModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="Active Sessions"
id="activity_log.activeSessions"
/>
</ModalTitle>
</ModalHeader>
<ModalBody
bsClass="modal-body"
componentClass="div"
>
<p
className="session-help-text"
>
<MemoizedFormattedMessage
defaultMessage="Sessions are created when you log in through a new browser on a device. Sessions let you use Mattermost without having to log in again for a time period specified by the system administrator. To end the session sooner, use the 'Log Out' button."
id="activity_log.sessionsDescription"
/>
</p>
<form
role="form"
/>
</ModalBody>
<ModalFooter
bsClass="modal-footer"
className="modal-footer--invisible"
componentClass="div"
>
<button
className="btn btn-tertiary"
id="closeModalButton"
type="button"
>
<MemoizedFormattedMessage
defaultMessage="Close"
id="general_button.close"
/>
</button>
</ModalFooter>
</Modal>
`;
exports[`components/ActivityLogModal should match snapshot when submitRevoke is called 1`] = `
<Modal
animation={true}
aria-labelledby="activityLogModalLabel"
autoFocus={true}
backdrop={true}
bsClass="modal"
bsSize="large"
dialogClassName="a11y__modal modal--scroll"
dialogComponentClass={[Function]}
enforceFocus={true}
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="none"
show={true}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<ModalTitle
bsClass="modal-title"
componentClass="h1"
id="activityLogModalLabel"
>
<MemoizedFormattedMessage
defaultMessage="Active Sessions"
id="activity_log.activeSessions"
/>
</ModalTitle>
</ModalHeader>
<ModalBody
bsClass="modal-body"
componentClass="div"
>
<p
className="session-help-text"
>
<MemoizedFormattedMessage
defaultMessage="Sessions are created when you log in through a new browser on a device. Sessions let you use Mattermost without having to log in again for a time period specified by the system administrator. To end the session sooner, use the 'Log Out' button."
id="activity_log.sessionsDescription"
/>
</p>
<form
role="form"
/>
</ModalBody>
<ModalFooter
bsClass="modal-footer"
className="modal-footer--invisible"
componentClass="div"
>
<button
className="btn btn-tertiary"
id="closeModalButton"
type="button"
>
<MemoizedFormattedMessage
defaultMessage="Close"
id="general_button.close"
/>
</button>
</ModalFooter>
</Modal>
`;
@@ -0,0 +1,6 @@
.activity-log-modal {
min-width: 40vw;
.GenericModal__body {
padding: 24px 24px 0;
}
}
@@ -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 (
<div
data-testid='activity-log'
data-session-id={currentSession.id}
onClick={(e) => submitRevoke(currentSession.id, e as MouseEvent)}
>
{'Activity Log Item'}
</div>
);
});
});
describe('components/ActivityLogModal', () => {
const baseProps = {
sessions: [],
@@ -21,14 +37,58 @@ describe('components/ActivityLogModal', () => {
locale: General.DEFAULT_LOCALE,
};
test('should match snapshot', () => {
const wrapper = shallow<ActivityLogModal>(
<ActivityLogModal {...baseProps}/>,
);
expect(wrapper).toMatchSnapshot();
test('should render empty state when no sessions exist', () => {
renderWithContext(<ActivityLogModal {...baseProps}/>);
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(
<ActivityLogModal
{...baseProps}
sessions={sessions}
/>,
);
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(
<ActivityLogModal
{...baseProps}
sessions={sessions}
/>,
);
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(<ActivityLogModal {...props}/>);
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<void>((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<ActivityLogModal>(
<ActivityLogModal {...props}/>,
const sessions = [
{id: 'session1', props: {type: 'Web'}},
] as any;
renderWithContext(
<ActivityLogModal
{...baseProps}
sessions={sessions}
actions={actions}
currentUserId='user1'
/>,
);
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<ActivityLogModal>(
<ActivityLogModal {...props}/>,
test('should call onHide when modal is closed', async () => {
const onHide = jest.fn();
renderWithContext(
<ActivityLogModal
{...baseProps}
onHide={onHide}
/>,
);
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<ActivityLogModal>(
<ActivityLogModal {...baseProps}/>,
);
wrapper.setState({show: true});
wrapper.instance().onHide();
expect(wrapper.state('show')).toEqual(false);
expect(onHide).toHaveBeenCalledTimes(1);
});
});
@@ -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<Props, State> {
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<Props, State>
<ActivityLog
key={currentSession.id}
index={index}
locale={this.props.locale}
locale={locale}
currentSession={currentSession}
submitRevoke={this.submitRevoke}
submitRevoke={submitRevoke}
/>,
);
return array;
}, []);
}, [sessions, locale, submitRevoke]);
const content = <form role='form'>{activityList}</form>;
const content = <form>{activityList}</form>;
return (
<Modal
dialogClassName='a11y__modal modal--scroll'
show={this.state.show}
onHide={this.onHide}
onExited={this.props.onHide}
bsSize='large'
role='none'
aria-labelledby='activityLogModalLabel'
>
<Modal.Header closeButton={true}>
<Modal.Title
componentClass='h1'
id='activityLogModalLabel'
>
<FormattedMessage
id='activity_log.activeSessions'
defaultMessage='Active Sessions'
/>
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className='session-help-text'>
<FormattedMessage
id='activity_log.sessionsDescription'
defaultMessage="Sessions are created when you log in through a new browser on a device. Sessions let you use Mattermost without having to log in again for a time period specified by the system administrator. To end the session sooner, use the 'Log Out' button."
/>
</p>
{content}
</Modal.Body>
<Modal.Footer className='modal-footer--invisible'>
<button
id='closeModalButton'
type='button'
className='btn btn-tertiary'
>
<FormattedMessage
id='general_button.close'
defaultMessage='Close'
/>
</button>
</Modal.Footer>
</Modal>
);
}
}
return (
<GenericModal
id='activityLogModal'
className='activity-log-modal modal--scroll'
modalHeaderText={
<FormattedMessage
id='activity_log.activeSessions'
defaultMessage='Active Sessions'
/>
}
show={true}
onHide={onHide}
ariaLabelledby='activityLogModalLabel'
modalLocation='top'
isStacked={true}
compassDesign={true}
>
<div className='activity-log-modal__body'>
<p className='session-help-text'>
<FormattedMessage
id='activity_log.sessionsDescription'
defaultMessage="Sessions are created when you log in through a new browser on a device. Sessions let you use Mattermost without having to log in again for a time period specified by the system administrator. To end the session sooner, use the 'Log Out' button."
/>
</p>
{content}
</div>
</GenericModal>
);
};
export default React.memo(ActivityLogModal);
@@ -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(<ChannelSettingsArchiveTab {...baseProps}/>);
// 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);
}
});
});
@@ -97,6 +97,7 @@ function ChannelSettingsArchiveTab({
confirmButtonClass='btn btn-danger'
modalClass='archiveChannelConfirmModal'
focusOriginElement='channelSettingsArchiveChannelButton'
isStacked={true}
/>
)}
</div>
@@ -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<Props, State> {
ariaLabelledby='confirmModalLabel'
compassDesign={true}
modalHeaderText={this.props.title}
isStacked={this.props.isStacked}
>
<div
data-testid={this.props.id}
@@ -537,6 +537,7 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn
restoreFocus={true}
role="none"
show={false}
style={Object {}}
>
<Modal
autoFocus={true}
@@ -1133,6 +1134,7 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn
restoreFocus={true}
role="none"
show={false}
style={Object {}}
>
<Modal
autoFocus={true}
@@ -526,6 +526,7 @@ exports[`components/integrations/AddOutgoingOAuthConnection should match snapsho
restoreFocus={true}
role="none"
show={false}
style={Object {}}
>
<Modal
autoFocus={true}
@@ -593,6 +593,7 @@ https://myothersite.com/api/v2"
restoreFocus={true}
role="none"
show={false}
style={Object {}}
>
<Modal
autoFocus={true}
@@ -703,6 +704,7 @@ https://myothersite.com/api/v2"
restoreFocus={true}
role="none"
show={false}
style={Object {}}
>
<Modal
autoFocus={true}
@@ -1337,6 +1339,7 @@ https://myothersite.com/api/v2"
restoreFocus={true}
role="none"
show={false}
style={Object {}}
>
<Modal
autoFocus={true}
@@ -1447,6 +1450,7 @@ https://myothersite.com/api/v2"
restoreFocus={true}
role="none"
show={false}
style={Object {}}
>
<Modal
autoFocus={true}
@@ -2080,6 +2084,7 @@ https://myothersite.com/api/v2"
restoreFocus={true}
role="none"
show={false}
style={Object {}}
>
<Modal
autoFocus={true}
@@ -2190,6 +2195,7 @@ https://myothersite.com/api/v2"
restoreFocus={true}
role="none"
show={false}
style={Object {}}
>
<Modal
autoFocus={true}
@@ -32,6 +32,7 @@ exports[`components/marketplace/ doesn't show web marketplace banner in FeatureF
restoreFocus={true}
role="none"
show={true}
style={Object {}}
>
<div
className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
@@ -143,6 +144,7 @@ exports[`components/marketplace/ hides search, shows web marketplace banner in F
restoreFocus={true}
role="none"
show={true}
style={Object {}}
>
<div
className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
@@ -255,6 +257,7 @@ exports[`components/marketplace/ should render default 1`] = `
restoreFocus={true}
role="none"
show={true}
style={Object {}}
>
<div
className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
@@ -418,6 +421,7 @@ exports[`components/marketplace/ should render with error banner 1`] = `
restoreFocus={true}
role="none"
show={true}
style={Object {}}
>
<div
className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
@@ -598,6 +602,7 @@ exports[`components/marketplace/ should render with no plugins available 1`] = `
restoreFocus={true}
role="none"
show={true}
style={Object {}}
>
<div
className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
@@ -769,6 +774,7 @@ exports[`components/marketplace/ should render with plugins available 1`] = `
restoreFocus={true}
role="none"
show={true}
style={Object {}}
>
<div
className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
@@ -958,6 +964,7 @@ exports[`components/marketplace/ should render with plugins installed 1`] = `
restoreFocus={true}
role="none"
show={true}
style={Object {}}
>
<div
className="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
@@ -121,7 +121,11 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"WrappedComponent": Object {
"$$typeof": Symbol(react.memo),
"compare": null,
"type": [Function],
},
"compare": null,
"type": [Function],
}
@@ -264,7 +268,11 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"WrappedComponent": Object {
"$$typeof": Symbol(react.memo),
"compare": null,
"type": [Function],
},
"compare": null,
"type": [Function],
}
@@ -407,7 +415,11 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"WrappedComponent": Object {
"$$typeof": Symbol(react.memo),
"compare": null,
"type": [Function],
},
"compare": null,
"type": [Function],
}
@@ -550,7 +562,11 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"WrappedComponent": Object {
"$$typeof": Symbol(react.memo),
"compare": null,
"type": [Function],
},
"compare": null,
"type": [Function],
}
@@ -694,7 +710,11 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"WrappedComponent": Object {
"$$typeof": Symbol(react.memo),
"compare": null,
"type": [Function],
},
"compare": null,
"type": [Function],
}
@@ -39,7 +39,6 @@
}
.modal-body {
max-height: 100%;
padding: 0;
&.divider {
@@ -98,6 +97,8 @@
&.GenericModal__compassDesign {
.modal-content {
max-height: 90vh;
.modal-body {
.GenericModal__body {
padding: 0;
@@ -7,6 +7,7 @@ import {Modal} from 'react-bootstrap';
import {FormattedMessage, useIntl} from 'react-intl';
import {useFocusTrap} from '../hooks/useFocusTrap';
import {useStackedModal} from '../hooks/useStackedModal';
import './generic_modal.scss';
export type ModalLocation = 'top' | 'center' | 'bottom';
@@ -17,6 +18,7 @@ export type Props = {
onEntered?: () => 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<Props> = ({
onEntered,
onHide,
modalHeaderText,
modalHeaderTextId,
modalSubheaderText,
handleCancel,
handleConfirm,
@@ -126,6 +136,7 @@ export const GenericModal: React.FC<Props> = ({
headerButton,
dataTestId,
delayFocusTrap,
isStacked = false,
}) => {
const intl = useIntl();
@@ -134,20 +145,27 @@ export const GenericModal: React.FC<Props> = ({
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<HTMLButtonElement, MouseEvent>) => {
event.preventDefault();
if (autoCloseOnCancelButton) {
@@ -231,7 +249,7 @@ export const GenericModal: React.FC<Props> = ({
// Build header text if provided.
const headerText = modalHeaderText && (
<div className='GenericModal__header'>
<h1 id='genericModalLabel' className='modal-title'>
<h1 id={modalHeaderTextId || 'genericModalLabel'} className='modal-title'>
{modalHeaderText}
</h1>
{headerButton}
@@ -277,12 +295,14 @@ export const GenericModal: React.FC<Props> = ({
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}
>
<div
ref={containerRef}
@@ -0,0 +1,197 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {render, screen} from '@testing-library/react';
import React from 'react';
import {useStackedModal} from './useStackedModal';
import {GenericModal} from '../generic_modal/generic_modal';
import {wrapIntl} from '../testUtils';
// Z-index constants from the hook implementation
const BASE_MODAL_Z_INDEX = 1050;
const Z_INDEX_INCREMENT = 10;
// Mock component that directly uses the useStackedModal hook
const TestComponent = ({
isStacked = false,
isOpen = true,
}) => {
const {shouldRenderBackdrop, modalStyle} = useStackedModal(isStacked, isOpen);
return (
<div data-testid='test-component'>
<div data-testid='should-render-backdrop'>{shouldRenderBackdrop.toString()}</div>
<div data-testid='modal-z-index'>{modalStyle.zIndex || 'none'}</div>
<div>Modal Content</div>
</div>
);
};
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: <div>Regular Modal Content</div>,
};
render(
wrapIntl(<GenericModal {...props}/>),
);
// 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: <div>Stacked Modal Content</div>,
};
render(
wrapIntl(<GenericModal {...props}/>),
);
// 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: <div>Stacked Modal Content</div>,
};
render(
wrapIntl(<GenericModal {...stackedProps}/>),
);
// 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(<TestComponent isStacked={false}/>);
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(<TestComponent isStacked={true}/>);
// 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(<TestComponent isStacked={true}/>);
// 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(<TestComponent isStacked={true}/>);
// 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(<TestComponent isStacked={true}/>);
// 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(<TestComponent isStacked={true}/>);
// 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');
});
});
});
@@ -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<HTMLElement | null>;
};
/**
* 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<HTMLElement | null>(null);
// Ref to store the original z-index of the parent modal's backdrop
const originalBackdropZIndexRef = useRef<string | null>(null);
// Ref to store the parent modal's backdrop element
const backdropRef = useRef<HTMLElement | null>(null);
// Ref to store the original opacity of the parent modal's backdrop
const originalBackdropOpacityRef = useRef<string | null>(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;