Add transient notices (https://github.com/woocommerce/woocommerce-admin/pull/1423)
* Add initial transient notices component * Animate notices to slide in and out * Add notices to wc-api * Map notices from wc-api to transient notices * Add success message on settings save * Add save notices to Settings page * Remove all margin and padding from notice box when empty * Add role alert to transient notices * Add prefers reduced motion media query for transient animation * Remove initial test notice * Remove notices from wc-api * Add wc-admin store for transient notices * Pull transient notices from newly implemented store * Use speak instead of role alert for accessibility * Only show success message after request is complete * Destructure state in componentDidUpdate * Add store notice tests and fixtures * Add box shadow to transient notice * Move transient notices to bottom left * Fix indentation
This commit is contained in:
parent
4962ee5e4e
commit
847b59246d
|
@ -21,6 +21,7 @@ import './index.scss';
|
|||
import { analyticsSettings } from './config';
|
||||
import Header from 'header';
|
||||
import Setting from './setting';
|
||||
import withSelect from 'wc-api/with-select';
|
||||
|
||||
const SETTINGS_FILTER = 'woocommerce_admin_analytics_settings';
|
||||
|
||||
|
@ -33,6 +34,7 @@ class Settings extends Component {
|
|||
|
||||
this.state = {
|
||||
settings: settings,
|
||||
saving: false,
|
||||
};
|
||||
|
||||
this.handleInputChange = this.handleInputChange.bind( this );
|
||||
|
@ -59,9 +61,31 @@ class Settings extends Component {
|
|||
}
|
||||
};
|
||||
|
||||
componentDidUpdate() {
|
||||
const { addNotice, isError, isRequesting } = this.props;
|
||||
const { saving } = this.state;
|
||||
|
||||
if ( saving && ! isRequesting ) {
|
||||
if ( ! isError ) {
|
||||
addNotice( {
|
||||
status: 'success',
|
||||
message: __( 'Your settings have been successfully saved.', 'wc-admin' ),
|
||||
} );
|
||||
} else {
|
||||
addNotice( {
|
||||
status: 'error',
|
||||
message: __( 'There was an error saving your settings. Please try again.', 'wc-admin' ),
|
||||
} );
|
||||
}
|
||||
/* eslint-disable react/no-did-update-set-state */
|
||||
this.setState( { saving: false } );
|
||||
/* eslint-enable react/no-did-update-set-state */
|
||||
}
|
||||
}
|
||||
|
||||
saveChanges = () => {
|
||||
this.props.updateSettings( this.state.settings );
|
||||
// @todo Need a confirmation on successful update.
|
||||
this.setState( { saving: true } );
|
||||
};
|
||||
|
||||
handleInputChange( e ) {
|
||||
|
@ -124,10 +148,21 @@ class Settings extends Component {
|
|||
}
|
||||
|
||||
export default compose(
|
||||
withSelect( select => {
|
||||
const { getSettings, getSettingsError, isGetSettingsRequesting } = select( 'wc-api' );
|
||||
|
||||
const settings = getSettings();
|
||||
const isError = Boolean( getSettingsError() );
|
||||
const isRequesting = isGetSettingsRequesting();
|
||||
|
||||
return { getSettings, isError, isRequesting, settings };
|
||||
} ),
|
||||
withDispatch( dispatch => {
|
||||
const { addNotice } = dispatch( 'wc-admin' );
|
||||
const { updateSettings } = dispatch( 'wc-api' );
|
||||
|
||||
return {
|
||||
addNotice,
|
||||
updateSettings,
|
||||
};
|
||||
} )
|
||||
|
|
|
@ -10,6 +10,7 @@ import { Provider as SlotFillProvider } from 'react-slot-fill';
|
|||
*/
|
||||
import './stylesheets/_embedded.scss';
|
||||
import { EmbedLayout } from './layout';
|
||||
import 'store';
|
||||
import 'wc-api/wp-data-store';
|
||||
|
||||
render(
|
||||
|
|
|
@ -10,6 +10,7 @@ import { Provider as SlotFillProvider } from 'react-slot-fill';
|
|||
*/
|
||||
import './stylesheets/_index.scss';
|
||||
import { PageLayout } from './layout';
|
||||
import 'store';
|
||||
import 'wc-api/wp-data-store';
|
||||
|
||||
render(
|
||||
|
|
|
@ -21,6 +21,7 @@ import { Controller, getPages } from './controller';
|
|||
import Header from 'header';
|
||||
import Notices from './notices';
|
||||
import { recordPageView } from 'lib/tracks';
|
||||
import TransientNotices from './transient-notices';
|
||||
|
||||
class Layout extends Component {
|
||||
componentDidMount() {
|
||||
|
@ -62,6 +63,8 @@ class Layout extends Component {
|
|||
return (
|
||||
<div className="woocommerce-layout">
|
||||
{ window.wcAdminFeatures[ 'activity-panels' ] && <Slot name="header" /> }
|
||||
<TransientNotices />
|
||||
|
||||
<div className="woocommerce-layout__primary" id="woocommerce-layout__primary">
|
||||
<Notices />
|
||||
{ ! isEmbeded && (
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
/** @format */
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { Component } from '@wordpress/element';
|
||||
import { compose } from '@wordpress/compose';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
import TransientNotice from './transient-notice';
|
||||
import withSelect from 'wc-api/with-select';
|
||||
|
||||
class TransientNotices extends Component {
|
||||
render() {
|
||||
const { className, notices } = this.props;
|
||||
const classes = classnames( 'woocommerce-transient-notices', className );
|
||||
|
||||
return (
|
||||
<section className={ classes }>
|
||||
{ notices && notices.map( ( notice, i ) => <TransientNotice key={ i } { ...notice } /> ) }
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TransientNotices.propTypes = {
|
||||
/**
|
||||
* Additional class name to style the component.
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
/**
|
||||
* Array of notices to be displayed.
|
||||
*/
|
||||
notices: PropTypes.array,
|
||||
};
|
||||
|
||||
export default compose(
|
||||
withSelect( select => {
|
||||
const { getNotices } = select( 'wc-admin' );
|
||||
const notices = getNotices();
|
||||
|
||||
return { notices };
|
||||
} )
|
||||
)( TransientNotices );
|
|
@ -0,0 +1,38 @@
|
|||
/** @format */
|
||||
|
||||
.woocommerce-transient-notices {
|
||||
position: fixed;
|
||||
bottom: $gap-small;
|
||||
left: 0;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.woocommerce-transient-notice {
|
||||
transform: translateX(calc(-100% - #{$gap}));
|
||||
transition: all 300ms cubic-bezier(0.42, 0, 0.58, 1);
|
||||
max-height: 300px; // Used to animate sliding down when multiple notices exist on exit.
|
||||
|
||||
@media screen and (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
&.slide-enter-active,
|
||||
&.slide-enter-done {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
|
||||
&.slide-exit-active {
|
||||
transform: translateX(calc(-100% - #{$gap}));
|
||||
}
|
||||
|
||||
&.slide-exit-done {
|
||||
max-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.components-notice {
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
/** @format */
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { Component } from '@wordpress/element';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import { noop } from 'lodash';
|
||||
import { Notice } from '@wordpress/components';
|
||||
import PropTypes from 'prop-types';
|
||||
import { speak } from '@wordpress/a11y';
|
||||
|
||||
class TransientNotice extends Component {
|
||||
constructor( props ) {
|
||||
super( props );
|
||||
|
||||
this.state = {
|
||||
visible: false,
|
||||
timeout: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const exitTime = this.props.exitTime;
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
this.setState( { visible: false } );
|
||||
},
|
||||
exitTime,
|
||||
name,
|
||||
exitTime
|
||||
);
|
||||
/* eslint-disable react/no-did-mount-set-state */
|
||||
this.setState( { visible: true, timeout } );
|
||||
/* eslint-enable react/no-did-mount-set-state */
|
||||
speak( this.props.message );
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout( this.state.timeout );
|
||||
}
|
||||
|
||||
render() {
|
||||
const { actions, className, isDismissible, message, onRemove, status } = this.props;
|
||||
const classes = classnames( 'woocommerce-transient-notice', className );
|
||||
|
||||
return (
|
||||
<CSSTransition in={ this.state.visible } timeout={ 300 } classNames="slide">
|
||||
<div className={ classes }>
|
||||
<Notice
|
||||
status={ status }
|
||||
isDismissible={ isDismissible }
|
||||
onRemove={ onRemove }
|
||||
actions={ actions }
|
||||
>
|
||||
{ message }
|
||||
</Notice>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TransientNotice.propTypes = {
|
||||
/**
|
||||
* Array of action objects.
|
||||
* See https://wordpress.org/gutenberg/handbook/designers-developers/developers/components/notice/
|
||||
*/
|
||||
actions: PropTypes.array,
|
||||
/**
|
||||
* Additional class name to style the component.
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
/**
|
||||
* Determines if the notice dimiss button should be shown.
|
||||
* See https://wordpress.org/gutenberg/handbook/designers-developers/developers/components/notice/
|
||||
*/
|
||||
isDismissible: PropTypes.bool,
|
||||
/**
|
||||
* Function called when dismissing the notice.
|
||||
* See https://wordpress.org/gutenberg/handbook/designers-developers/developers/components/notice/
|
||||
*/
|
||||
onRemove: PropTypes.func,
|
||||
/**
|
||||
* Type of notice to display.
|
||||
* See https://wordpress.org/gutenberg/handbook/designers-developers/developers/components/notice/
|
||||
*/
|
||||
status: PropTypes.oneOf( [ 'success', 'error', 'warning' ] ),
|
||||
/**
|
||||
* Time in milliseconds until exit.
|
||||
*/
|
||||
exitTime: PropTypes.number,
|
||||
};
|
||||
|
||||
TransientNotice.defaultProps = {
|
||||
actions: [],
|
||||
className: '',
|
||||
exitTime: 7000,
|
||||
isDismissible: false,
|
||||
onRemove: noop,
|
||||
status: 'warning',
|
||||
};
|
||||
|
||||
export default TransientNotice;
|
|
@ -0,0 +1,22 @@
|
|||
/** @format */
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { combineReducers, registerStore } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import notices from './notices';
|
||||
|
||||
registerStore( 'wc-admin', {
|
||||
reducer: combineReducers( {
|
||||
...notices.reducers,
|
||||
} ),
|
||||
actions: {
|
||||
...notices.actions,
|
||||
},
|
||||
selectors: {
|
||||
...notices.selectors,
|
||||
},
|
||||
} );
|
|
@ -0,0 +1,9 @@
|
|||
/** @format */
|
||||
|
||||
const addNotice = notice => {
|
||||
return { type: 'ADD_NOTICE', notice };
|
||||
};
|
||||
|
||||
export default {
|
||||
addNotice,
|
||||
};
|
|
@ -0,0 +1,13 @@
|
|||
/** @format */
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import reducers from './reducers';
|
||||
import actions from './actions';
|
||||
import selectors from './selectors';
|
||||
|
||||
export default {
|
||||
reducers,
|
||||
actions,
|
||||
selectors,
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
/** @format */
|
||||
|
||||
const DEFAULT_STATE = [];
|
||||
|
||||
const notices = ( state = DEFAULT_STATE, action ) => {
|
||||
if ( action.type === 'ADD_NOTICE' ) {
|
||||
return [ ...state, action.notice ];
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export default {
|
||||
notices,
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
/** @format */
|
||||
|
||||
const getNotices = state => {
|
||||
return state.notices;
|
||||
};
|
||||
|
||||
export default {
|
||||
getNotices,
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
/** @format */
|
||||
|
||||
export const DEFAULT_STATE = {
|
||||
notices: [],
|
||||
};
|
||||
|
||||
export const testNotice = { message: 'Test notice' };
|
|
@ -0,0 +1,55 @@
|
|||
/** @format */
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import actions from '../actions';
|
||||
import { DEFAULT_STATE, testNotice } from './fixtures';
|
||||
import reducers from '../reducers';
|
||||
import selectors from '../selectors';
|
||||
|
||||
describe( 'actions', () => {
|
||||
test( 'should create an add notice action', () => {
|
||||
const expectedAction = {
|
||||
type: 'ADD_NOTICE',
|
||||
notice: testNotice,
|
||||
};
|
||||
|
||||
expect( actions.addNotice( testNotice ) ).toEqual( expectedAction );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'selectors', () => {
|
||||
const notices = [ testNotice ];
|
||||
const updatedState = { ...DEFAULT_STATE, ...{ notices } };
|
||||
|
||||
test( 'should return an emtpy initial state', () => {
|
||||
expect( selectors.getNotices( DEFAULT_STATE ) ).toEqual( [] );
|
||||
} );
|
||||
|
||||
test( 'should have an array length matching number of notices', () => {
|
||||
expect( selectors.getNotices( updatedState ).length ).toEqual( 1 );
|
||||
} );
|
||||
|
||||
test( 'should return the message content', () => {
|
||||
expect( selectors.getNotices( updatedState )[ 0 ].message ).toEqual( 'Test notice' );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'reducers', () => {
|
||||
test( 'should return an emtpy initial state', () => {
|
||||
expect( reducers.notices( DEFAULT_STATE.notices, {} ) ).toEqual( [] );
|
||||
} );
|
||||
|
||||
test( 'should return the added notice', () => {
|
||||
expect(
|
||||
reducers.notices( DEFAULT_STATE.notices, { type: 'ADD_NOTICE', notice: testNotice } )
|
||||
).toEqual( [ testNotice ] );
|
||||
} );
|
||||
|
||||
const initialNotices = [ { message: 'Initial notice' } ];
|
||||
test( 'should return the initial notice and the added notice', () => {
|
||||
expect(
|
||||
reducers.notices( initialNotices, { type: 'ADD_NOTICE', notice: testNotice } )
|
||||
).toEqual( [ ...initialNotices, testNotice ] );
|
||||
} );
|
||||
} );
|
|
@ -48,13 +48,13 @@ function updateSettings( resourceNames, data, fetch ) {
|
|||
method: 'POST',
|
||||
data: { value: settingsData[ setting ] },
|
||||
} )
|
||||
.then( settingsToSettingsResource )
|
||||
.then( settingToSettingsResource.bind( null, data.settings ) )
|
||||
.catch( error => {
|
||||
return { [ resourceName ]: { error } };
|
||||
} );
|
||||
} );
|
||||
|
||||
return [ promises ];
|
||||
return promises;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
@ -65,6 +65,11 @@ function settingsToSettingsResource( settings ) {
|
|||
return { [ 'settings' ]: { data: settingsData } };
|
||||
}
|
||||
|
||||
function settingToSettingsResource( settings, setting ) {
|
||||
settings[ setting.id ] = setting.value;
|
||||
return { [ 'settings' ]: { data: settings } };
|
||||
}
|
||||
|
||||
export default {
|
||||
read,
|
||||
update,
|
||||
|
|
|
@ -1,14 +1,34 @@
|
|||
/** @format */
|
||||
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { isNil } from 'lodash';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { DEFAULT_REQUIREMENT } from '../constants';
|
||||
|
||||
const getSettings = ( getResource, requireResource ) => ( requirement = DEFAULT_REQUIREMENT ) => {
|
||||
return requireResource( requirement, 'settings' ).data;
|
||||
return requireResource( requirement, 'settings' ).data || {};
|
||||
};
|
||||
|
||||
const getSettingsError = getResource => () => {
|
||||
return getResource( 'settings' ).error;
|
||||
};
|
||||
|
||||
const isGetSettingsRequesting = getResource => () => {
|
||||
const { lastRequested, lastReceived } = getResource( 'settings' );
|
||||
if ( isNil( lastRequested ) || isNil( lastReceived ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return lastRequested > lastReceived;
|
||||
};
|
||||
|
||||
export default {
|
||||
getSettings,
|
||||
getSettingsError,
|
||||
isGetSettingsRequesting,
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue