Skip to content

Commit

Permalink
ENH Display toast notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
emteknetnz committed Mar 29, 2021
1 parent ad407bb commit a4ac5e9
Show file tree
Hide file tree
Showing 17 changed files with 825 additions and 166 deletions.
2 changes: 1 addition & 1 deletion client/dist/js/bundle.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions client/dist/js/bundle.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion client/dist/styles/bundle.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions client/dist/styles/bundle.css.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions client/lang/src/en/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,5 @@
"SessionManager.LAST_ACTIVE": "last active {lastAccessedElapsed}...",
"SessionManager.LOG_OUT": "Log out",
"SessionManager.LOGGING_OUT": "Logging out...",
"SessionManager.LOG_OUT_CONFIRMED": "Successfully logged out of device.",
"SessionManager.ACTIVITY_TOOLTIP_TEXT": "Signed in {signedIn}, Last active {lastActive}",
"SessionManager.ACTIVITY_TOOLTIP_TEXT": "Signed in {signedIn}, Last active {lastActive}"
}
97 changes: 50 additions & 47 deletions client/src/components/LoginSession/LoginSession.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
/* global window */
import React, { useState } from 'react';
import confirm from '@silverstripe/reactstrap-confirm';
import { success, error } from 'state/toasts/ToastsActions';
import Config from 'lib/Config'; // eslint-disable-line
import backend from 'lib/Backend';
import moment from 'moment';
import i18n from 'i18n';
import PropTypes from 'prop-types';
import jQuery from 'jquery';
import 'regenerator-runtime/runtime';
import { connect } from 'react-redux';

function LoginSession(props) {
const [loading, setLoading] = useState({ complete: false, failed: false, submitting: false });

async function logOut() {
function logOut() {
setLoading({ ...loading, submitting: true });

const endpoint = backend.createEndpointFetcher({
Expand All @@ -29,42 +29,25 @@ function LoginSession(props) {
SecurityID: Config.get('SecurityID')
})
.then(response => {
setLoading({
complete: true,
failed: !!response.error && !response.success,
submitting: false
});

if (response.success) {
setTimeout(() => {
setLoading({
complete: true,
failed: !!response.error && !response.success,
fadeOutComplete: true,
submitting: false
});

jQuery.noticeAdd({
text: i18n._t(
'SessionManager.LOG_OUT_CONFIRMED',
'Successfully logged out of device.'
),
stay: false,
type: 'success'
});
}, 2000);
const failed = !response.success;
setLoading({ complete: true, failed, submitting: false });
if (failed) {
props.displayToastFailure(response.message);
} else {
props.displayToastSuccess(response.message);
}
})
.catch((error) => {
.catch(() => {
setLoading({ complete: true, failed: true, submitting: false });

error.response.json().then(response => {
jQuery.noticeAdd({ text: response.errors, stay: false, type: 'error' });
});
});
}

// This is an async function because 'confirm' requires it
// https://www.npmjs.com/package/@silverstripe/reactstrap-confirm
async function attemptLogOut() {
if (loading.submitting) {
return;
}
// Confirm with the user
const confirmMessage = i18n._t(
'SessionManager.DELETE_CONFIRMATION',
Expand All @@ -80,9 +63,12 @@ function LoginSession(props) {
logOut();
}

if (loading.fadeOutComplete) {
return null;
}
const enterKeyPressed = (event) => {
// ie11 not supported https://caniuse.com/keyboardevent-code
const hasConst = typeof KeyboardEvent !== 'undefined' && KeyboardEvent.hasOwnProperty('DOM_VK_RETURN');
const charCode = hasConst ? KeyboardEvent.DOM_VK_RETURN : 13;
return event.which === charCode;
};

const created = moment(props.Created);
const createdElapsed = moment.utc(props.Created).fromNow();
Expand Down Expand Up @@ -119,19 +105,18 @@ function LoginSession(props) {
, {lastActiveStr}
</span>
</p>
{props.IsCurrent &&
<p>
<strong className={'text-success'}>
{currentStr}
</strong>
{props.IsCurrent &&
<strong className={'text-success'}>{currentStr}</strong>
}
{!props.IsCurrent && <a
role={'button'}
tabIndex={'0'}
className={'login-session__logout'}
onClick={() => attemptLogOut()}
onKeyDown={(event) => { if (enterKeyPressed(event)) { attemptLogOut(); } }}
>{logOutStr}</a>}
</p>
}
{!props.IsCurrent && <a
role={'button'}
tabIndex={'0'}
className={'login-session__logout'}
onClick={loading.submitting ? null : attemptLogOut}
>{logOutStr}</a>}
</div>
);
}
Expand All @@ -146,4 +131,22 @@ LoginSession.propTypes = {
LogOutEndpoint: PropTypes.string.isRequired,
};

export default LoginSession;
LoginSession.defaultProps = {
displayToastSuccess: () => 0,
displayToastFailure: () => 0,
};

export { LoginSession as Component };

function mapDispatchToProps(dispatch) {
return {
displayToastSuccess(message) {
dispatch(success(message));
},
displayToastFailure(message) {
dispatch(error(message));
},
};
}

export default connect(() => ({}), mapDispatchToProps)(LoginSession);
10 changes: 8 additions & 2 deletions client/src/components/LoginSession/LoginSession.scss
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
.login-session {
max-height: 100%;
&.hidden {
visibility: hidden;
opacity: 0;
transition: visibility 0s 2s, opacity 2s linear;
max-height: 0;
transition: visibility 0s 1s, opacity 1s linear, max-height 1s linear;
}

p {
margin: 0;

&:last-of-type {
padding-bottom: 20px;
}
}
}

Expand All @@ -16,7 +22,7 @@
font-weight: bold;
outline: none;

&:hover, &:active {
&:hover, &:active, &:focus {
text-decoration: underline;
}
}
14 changes: 13 additions & 1 deletion client/src/components/LoginSession/tests/LoginSession-story.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import LoginSession from 'components/LoginSession/LoginSession';
import { Component as LoginSession } from 'components/LoginSession/LoginSession';
import { withKnobs, boolean } from '@storybook/addon-knobs/react';
import backend from 'lib/Backend';

window.ss.config = {
SecurityID: 1234567890
};

const endpointFetcher = async () => ({
error: false,
success: true,
message: 'Success message',
});
backend.createEndpointFetcher = () => endpointFetcher;

const props = {
ID: 1,
Expand Down
88 changes: 60 additions & 28 deletions client/src/components/LoginSession/tests/LoginSession-test.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,39 @@
/* global jest, jasmine, describe, it, expect, beforeEach, Event */

import React from 'react';
import LoginSession from '../LoginSession';
import { Component } from '../LoginSession';
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import backend from 'lib/Backend';
import MockDate from 'mockdate';

// This is to simulate clicking the green 'Remove login session' button on the confirm modal
jest.mock('@silverstripe/reactstrap-confirm', () => jest.fn().mockImplementation(
() => Promise.resolve(true)
));

Enzyme.configure({ adapter: new Adapter() });

describe('LoginSession', () => {
let props = null;
window.ss.config = {
SecurityID: 1234567890
};

beforeEach(() => {
async function endpointFetcher() {
return {
error: false,
success: true
};
}
backend.createEndpointFetcher = jest.fn().mockImplementation(() => endpointFetcher);
const setEndpointFetcher = (success, message) => {
const endpointFetcher = async () => ({
error: false,
success,
message
});
backend.createEndpointFetcher = () => endpointFetcher;
};

describe('LoginSession', () => {
let wrapper;
let button;
let props;
beforeEach(() => {
setEndpointFetcher(true, 'message-success');
MockDate.set('2021-03-12 03:47:22');

// Set window config
window.ss.config = {
SecurityID: 1234567890
};

props = {
ID: 1,
IPAddress: '127.0.0.1',
Expand All @@ -40,22 +42,52 @@ describe('LoginSession', () => {
LastAccessed: '2021-03-11 03:47:22',
LogOutEndpoint: 'admin/loginsession/remove',
};
wrapper = shallow(<Component {...props} />);
button = wrapper.find('.login-session__logout');
});
it('should display details', () => {
const html = wrapper.html();
expect(html.indexOf('Chrome on Mac OS X 10.15.7')).not.toBe(false);
expect(html.indexOf('127.0.0.1')).not.toBe(false);
expect(html.indexOf('Signed in 01/20/2021 12:33 AM')).not.toBe(false); // TODO: is this date supposed to have US formatting?
expect(html.indexOf('Last active 03/11/2021 3:47 AM')).not.toBe(false); // TODO: is this date supposed to have US formatting?
});

describe('render()', () => {
it('should match the snapshot', () => {
const wrapper = shallow(<LoginSession {...props} />);
expect(wrapper.html()).toMatchSnapshot();
it('should display a logout button', () => {
expect(button).not.toBeNull();
expect(button.text()).toBe('Log out');
});

it('should log sessions out correctly', done => {
button.simulate('click');

// use setTimout() because LoginSession.attemptLogOut() is an aysnc function
setTimeout(() => {
// get a fresh version of button
button = wrapper.find('.login-session__logout');
expect(button.text()).toBe('Logging out...');
done();
});
});

it('should call a displayToastSuccess function on success', () => {
button.simulate('click');

// use setTimout() because LoginSession.attemptLogOut() is an aysnc function
setTimeout(() => {
expect(props.displayToastSuccess).toBeCalledTimes(1);
expect(props.displayToastFailure).toBeCalledTimes(0);
});
});

it('should log sessions out correctly', done => {
const wrapper = shallow(<LoginSession {...props} />);
wrapper.find('.login-session__logout').simulate('click');
it('should call a displayToastFailure function on failure', () => {
setEndpointFetcher(false, 'message-failure');
button.simulate('click');

setTimeout(() => {
expect(wrapper.html()).toMatchSnapshot();
done();
});
// use setTimout() because LoginSession.attemptLogOut() is an aysnc function
setTimeout(() => {
expect(props.displayToastSuccess).toBeCalledTimes(0);
expect(props.displayToastFailure).toBeCalledTimes(1);
});
});
});

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import PropTypes from 'prop-types';
function SessionManagerField(props) {
return (
<ul className={'session-manager-field list-unstyled'}>
{props.loginSessions.map((loginSession) =>
(<li key={loginSession.ID} className={'list-unstyled'}>
{props.loginSessions.map((loginSession) => (
<li key={loginSession.ID} className={'list-unstyled'}>
<LoginSession {...loginSession} />
</li>)
)}
</li>
))}
</ul>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
.session-manager-field {
padding: $input-padding-y 0;

li:not(:last-of-type) {
margin-bottom: 20px;
}
}
4 changes: 4 additions & 0 deletions lang/en.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
en:
SilverStripe\SessionManager\Job\GarbageCollectionJob:
TITLE: 'Session manager garbage collection'
SilverStripe\SessionManager\Control\LoginSessionController:
REMOVE_SUCCESS: 'Successfully logged out of device.'
REMOVE_FAILURE: 'Something went wrong.'
REMOVE_PERMISSION: 'You do not have permission to delete this record.'
Loading

0 comments on commit a4ac5e9

Please sign in to comment.