Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH Display toast notifications #39

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
});
};
107 changes: 23 additions & 84 deletions client/src/components/LoginSession/LoginSession.js
Original file line number Diff line number Diff line change
@@ -1,87 +1,28 @@
/* 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 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() {
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 });

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() {
maxime-rainville marked this conversation as resolved.
Show resolved Hide resolved
if (props.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 })) {
maxime-rainville marked this conversation as resolved.
Show resolved Hide resolved
return;
}

logOut();
}

if (loading.fadeOutComplete) {
return null;
props.logout();
Comment on lines 22 to +25

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To my eye this looks better, but the current code is fine.

        if (await confirm(confirmMessage, { title: confirmTitle, confirmLabel: buttonLabel })) {
          props.logout();
        }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logout is not define in your propTypes.

}

const created = moment(props.Created);
Expand All @@ -98,7 +39,7 @@ function LoginSession(props) {
i18n._t('SessionManager.LAST_ACTIVE', 'last active {lastAccessedElapsed}...'),
{ lastAccessedElapsed }
);
const logOutStr = (loading.submitting || (loading.complete && !loading.failed)) ?
const logOutStr = (props.submitting || (props.complete && !props.failed)) ?
i18n._t('SessionManager.LOGGING_OUT', 'Logging out...')
: i18n._t('SessionManager.LOG_OUT', 'Log out');

Expand All @@ -111,39 +52,37 @@ function LoginSession(props) {
);

return (
<div className={`login-session ${(loading.complete && !loading.failed) ? 'hidden' : ''}`}>
<div className={`login-session ${(props.complete && !props.failed) ? 'hidden' : ''}`}>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be clearer if you use the classnames lib for this kind of string building.

<p>{props.UserAgent}</p>
<p className="text-muted">
{props.IPAddress}
<span data-toggle="tooltip" data-placement="top" title={activityTooltip}>
, {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()}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a bit more succinct.

Suggested change
onClick={() => attemptLogOut()}
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,
emteknetnz marked this conversation as resolved.
Show resolved Hide resolved
LogOutEndpoint: PropTypes.string.isRequired,
submitting: PropTypes.bool.isRequired,
complete: PropTypes.bool.isRequired,
failed: PropTypes.bool.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;
maxime-rainville marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

.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;
}
107 changes: 107 additions & 0 deletions client/src/components/LoginSession/LoginSessionContainer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import PropTypes from 'prop-types';
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 {
maxime-rainville marked this conversation as resolved.
Show resolved Hide resolved
//
constructor(props) {
super(props);
this.createEndpoint = this.createEndpoint.bind(this);
this.logout = this.logout.bind(this);
}

componentWillMount() {
this.setState({
complete: false,
failed: false,
submitting: false
});
}

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

logout() {
this.setState({
...this.state,
submitting: true
});
const endpoint = this.createEndpoint();
endpoint({
id: this.props.ID,
SecurityID: Config.get('SecurityID')
})
.then(response => {
const failed = !response.success;
this.setState({
complete: true,
failed,
submitting: false
});
if (failed) {
this.props.displayToastFailure(response.message);
} else {
this.props.displayToastSuccess(response.message);
}
})
.catch(() => {
this.setState({
complete: true,
failed: true,
submitting: false
});
});
}

render() {
const { ID, LogoutEndPoint, ...loginSessionProps } = this.props;
const newProps = { logout: this.logout, ...this.state, ...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,
Comment on lines +87 to +91

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an example of how exporting your protypes independently can make your life easier.

Suggested change
IPAddress: PropTypes.string.isRequired,
IsCurrent: PropTypes.bool,
UserAgent: PropTypes.string,
Created: PropTypes.string.isRequired,
LastAccessed: PropTypes.string.isRequired,
...LoginSession.propTypes

};

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

export { LoginSessionContainer as Component };

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

const createDateMinutesAgo = (m) => {
const d1 = new Date();
const d2 = new Date(d1);
d2.setMinutes(d1.getMinutes() - m);
return d2.toISOString().replace(/[TZ]/g, ' ').replace(/\.[0-9]+ $/, '');
};

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',
Created: createDateMinutesAgo(120),
LastAccessed: createDateMinutesAgo(25),
logout: () => 1
};

storiesOf('SessionManager/LoginSession', module)
Expand All @@ -18,5 +24,8 @@ storiesOf('SessionManager/LoginSession', module)
<LoginSession
{...props}
IsCurrent={boolean('IsCurrent', false)}
submitting={boolean('Submitting', false)}
complete={boolean('Complete', false)}
failed={boolean('Failed', false)}
/>
));
Loading