* 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:
Joshua T Flowers 2019-02-13 19:44:58 +08:00 committed by GitHub
parent 4962ee5e4e
commit 847b59246d
16 changed files with 389 additions and 4 deletions

View File

@ -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,
};
} )

View File

@ -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(

View File

@ -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(

View File

@ -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 && (

View File

@ -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 );

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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,
},
} );

View File

@ -0,0 +1,9 @@
/** @format */
const addNotice = notice => {
return { type: 'ADD_NOTICE', notice };
};
export default {
addNotice,
};

View File

@ -0,0 +1,13 @@
/** @format */
/**
* Internal dependencies
*/
import reducers from './reducers';
import actions from './actions';
import selectors from './selectors';
export default {
reducers,
actions,
selectors,
};

View File

@ -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,
};

View File

@ -0,0 +1,9 @@
/** @format */
const getNotices = state => {
return state.notices;
};
export default {
getNotices,
};

View File

@ -0,0 +1,7 @@
/** @format */
export const DEFAULT_STATE = {
notices: [],
};
export const testNotice = { message: 'Test notice' };

View File

@ -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 ] );
} );
} );

View File

@ -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,

View File

@ -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,
};