mirror of
https://github.com/mattermost/mattermost.git
synced 2026-09-24 16:05:00 +08:00
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:
co-authored by
Mattermost Build
parent
a17c387ff2
commit
86cbe3cb18
-154
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
-165
@@ -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);
|
||||
|
||||
+32
@@ -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}
|
||||
|
||||
+2
@@ -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}
|
||||
|
||||
+1
@@ -526,6 +526,7 @@ exports[`components/integrations/AddOutgoingOAuthConnection should match snapsho
|
||||
restoreFocus={true}
|
||||
role="none"
|
||||
show={false}
|
||||
style={Object {}}
|
||||
>
|
||||
<Modal
|
||||
autoFocus={true}
|
||||
|
||||
+6
@@ -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}
|
||||
|
||||
+7
@@ -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"
|
||||
|
||||
+25
-5
@@ -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;
|
||||
Reference in New Issue
Block a user