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 31, 2021
1 parent 4901f43 commit 5183610
Show file tree
Hide file tree
Showing 19 changed files with 470 additions and 248 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}"
}
2 changes: 2 additions & 0 deletions client/src/boot/registerComponents.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import LoginSession from 'components/LoginSession/LoginSession';
import LoginSessionContainer from 'components/LoginSession/LoginSessionContainer';
import SessionManagerField from 'components/SessionManagerField/SessionManagerField';
import Injector from 'lib/Injector';

export default () => {
Injector.component.registerMany({
LoginSession,
LoginSessionContainer,
SessionManagerField,
});
};
101 changes: 26 additions & 75 deletions client/src/components/LoginSession/LoginSession.js
Original file line number Diff line number Diff line change
@@ -1,87 +1,42 @@
/* global window */
import React, { useState } from 'react';
import confirm from '@silverstripe/reactstrap-confirm';
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 React, { useState } from 'react';
import moment from 'moment';
import confirm from '@silverstripe/reactstrap-confirm';
import Button from 'components/Button/Button';

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

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

const endpoint = backend.createEndpointFetcher({
url: `${props.LogOutEndpoint}/:id`,
method: 'delete',
payloadSchema: {
id: { urlReplacement: ':id', remove: true },
SecurityID: { querystring: true }
}
});

endpoint({
id: props.ID,
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);
}
})
.catch((error) => {
setLoading({ complete: true, failed: true, submitting: false });
function receivedResponse(failed) {
setLoading({ complete: true, failed, submitting: false });
}

error.response.json().then(response => {
jQuery.noticeAdd({ text: response.errors, stay: false, type: 'error' });
});
});
function caughtException() {
setLoading({ complete: true, failed: true, submitting: false });
}

// 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',
'Are you sure you want to delete this login session?'
);
const confirmTitle = i18n._t('SessionManager.CONFIRMATION_TITLE', 'Are you sure?');
const buttonLabel = i18n._t('SessionManager.DELETE_CONFIRMATION_BUTTON', 'Remove login session');

if (!await confirm(confirmMessage, { title: confirmTitle, confirmLabel: buttonLabel })) {
return;
}

logOut();
}

if (loading.fadeOutComplete) {
return null;
props.logout(beforeRequest, receivedResponse, caughtException);
}

const created = moment(props.Created);
Expand Down Expand Up @@ -119,31 +74,27 @@ 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 && <Button
color="link"
className="login-session__logout"
onClick={() => attemptLogOut()}
>{logOutStr}</Button>}
</p>
}
{!props.IsCurrent && <a
role={'button'}
tabIndex={'0'}
className={'login-session__logout'}
onClick={loading.submitting ? null : attemptLogOut}
>{logOutStr}</a>}
</div>
);
}

LoginSession.propTypes = {
ID: PropTypes.number.isRequired,
IPAddress: PropTypes.string.isRequired,
IsCurrent: PropTypes.bool,
UserAgent: PropTypes.string,
Created: PropTypes.string.isRequired,
LastAccessed: PropTypes.string.isRequired,
LogOutEndpoint: PropTypes.string.isRequired,
logout: PropTypes.func.isRequired,
};

export default LoginSession;
18 changes: 9 additions & 9 deletions client/src/components/LoginSession/LoginSession.scss
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
.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;
}
}
}

.login-session__logout:not([href]) {
cursor: pointer;
color: $link-color;
.login-session__logout {
font-weight: bold;
outline: none;

&:hover, &:active {
text-decoration: underline;
}
padding: 0;
}
92 changes: 92 additions & 0 deletions client/src/components/LoginSession/LoginSessionContainer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import PropTypes from 'prop-types';
// import React, { Fragment } from 'react';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import backend from 'lib/Backend';
import Config from 'lib/Config'; // eslint-disable-line
import LoginSession from './LoginSession';
import { success, error } from 'state/toasts/ToastsActions';

// Handle communication with server on logout and toast notifications via redux
// Using a class component rather than function component so that LoginSessionContainer-test.js
// can use wrapper.instance() to test logout().
// .instance() does not work on stateless functional components
// https://enzymejs.github.io/enzyme/docs/api/ReactWrapper/instance.html
class LoginSessionContainer extends Component {
//
constructor(props) {
super(props);
this.createEndpoint = this.createEndpoint.bind(this);
this.logout = this.logout.bind(this);
}

createEndpoint() {
return backend.createEndpointFetcher({
url: `${this.props.LogOutEndpoint}/:id`,
method: 'delete',
payloadSchema: {
id: { urlReplacement: ':id', remove: true },
SecurityID: { querystring: true }
}
});
}

/**
* @param {Function} beforeRequest
* @param {Function} receivedResponse
* @param {Function} caughtException
*/
logout(beforeRequest, receivedResponse, caughtException) {
beforeRequest();
const endpoint = this.createEndpoint();
endpoint({
id: this.props.ID,
SecurityID: Config.get('SecurityID')
})
.then(response => {
const failed = !response.success;
receivedResponse(failed);
if (failed) {
this.props.displayToastFailure(response.message);
} else {
this.props.displayToastSuccess(response.message);
}
})
.catch(caughtException);
}

render() {
const { ID, LogoutEndPoint, ...loginSessionProps } = this.props;
const newProps = { logout: this.logout, ...loginSessionProps };
return <LoginSession {...newProps} />;
}
}

LoginSessionContainer.propTypes = {
// LoginSessionContainer specific:
ID: PropTypes.number.isRequired,
LogOutEndpoint: PropTypes.string.isRequired,
displayToastSuccess: PropTypes.func.isRequired,
displayToastFailure: PropTypes.func.isRequired,
// Passed on to LoginSession:
IPAddress: PropTypes.string.isRequired,
IsCurrent: PropTypes.bool,
UserAgent: PropTypes.string,
Created: PropTypes.string.isRequired,
LastAccessed: PropTypes.string.isRequired,
};

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

export { LoginSessionContainer as Component };

export default connect(() => ({}), mapDispatchToProps)(LoginSessionContainer);
10 changes: 8 additions & 2 deletions client/src/components/LoginSession/tests/LoginSession-story.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@ import LoginSession from 'components/LoginSession/LoginSession';
import { withKnobs, boolean } from '@storybook/addon-knobs/react';

const props = {
ID: 1,
IPAddress: '127.0.0.1',
UserAgent: 'Chrome on Mac OS X 10.15.7',
Created: '2021-01-20 00:33:41',
LastAccessed: '2021-03-11 03:47:22',
LogOutEndpoint: 'admin/loginsession/remove',
logout: (beforeRequest, receivedResponse, caughtException) => {
beforeRequest();
receivedResponse();
if (!caughtException) {
return 1;
}
return 1;
}
};

storiesOf('SessionManager/LoginSession', module)
Expand Down
Loading

0 comments on commit 5183610

Please sign in to comment.