Add Timeline component (https://github.com/woocommerce/woocommerce-admin/pull/3614)
Add Timeline component The overall component is split into smaller components: * Timeline item: an individual item (event) for the timeline. * Timeline group: a group of items grouped according to props. See README.md for prop details. Ordering and grouping is decided via props. Component also exports helper objects to make it easier to pass in the correct props for ordering and grouping. Item titles and bodies can be passed in as either strings or React elements. Icons must be React elements. Has a storybook scenario along with unit and snapshot tests. Co-authored-by: Valerie K <valerie@automattic.com> Co-authored-by: Allen Snook <allen@allendav.com> Co-authored-by: Kristófer R <kristofer.thorlaksson@automattic.com> Co-authored-by: David Levin <davidlevin@Davids-MacBook-Pro-2.local>
This commit is contained in:
parent
21d65f4bc8
commit
fe865b0c84
|
@ -1,4 +1,5 @@
|
|||
# 5.0.0 (Unreleased)
|
||||
- Added `<Timeline />` component.
|
||||
- Added `<ImageUpload />` component.
|
||||
- Style form components for WordPress 5.3.
|
||||
- Fix CompareFilter options format (key prop vs. id).
|
||||
|
|
|
@ -57,6 +57,7 @@ export { default as TableSummary } from './table/summary';
|
|||
export { default as Tag } from './tag';
|
||||
export { default as TextControl } from './text-control';
|
||||
export { default as TextControlWithAffixes } from './text-control-with-affixes';
|
||||
export { default as Timeline } from './timeline';
|
||||
export { default as useFilters } from './higher-order/use-filters';
|
||||
export { default as ViewMoreList } from './view-more-list';
|
||||
export { default as WebPreview } from './web-preview';
|
||||
|
|
|
@ -35,5 +35,6 @@
|
|||
@import 'tag/style.scss';
|
||||
@import 'text-control/style.scss';
|
||||
@import 'text-control-with-affixes/style.scss';
|
||||
@import 'timeline/style.scss';
|
||||
@import 'view-more-list/style.scss';
|
||||
@import 'web-preview/style.scss';
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
Timeline
|
||||
===
|
||||
|
||||
This is a timeline for displaying data, such as events, in chronological order.
|
||||
It accepts `items` for the timeline content and will order the data for you.
|
||||
|
||||
## Usage
|
||||
|
||||
```jsx
|
||||
import Timeline from './Timeline';
|
||||
import { orderByOptions, groupByOptions } from './Timeline';
|
||||
import GridIcon from 'gridicons';
|
||||
|
||||
const items = [
|
||||
{
|
||||
date: new Date( 2019, 9, 28, 9, 0 ),
|
||||
icon: <GridIcon icon={ 'checkmark' } />,
|
||||
headline: 'A payment of $90.00 was successfully charged',
|
||||
body: [
|
||||
<p key={ '1' }>{ 'Fee: $2.91 ( 2.9% + $0.30 )' }</p>,
|
||||
<p key={ '2' }>{ 'Net deposit: $87.09' }</p>,
|
||||
],
|
||||
},
|
||||
{
|
||||
date: new Date( 2019, 9, 28, 9, 32 ),
|
||||
icon: <GridIcon icon={ 'plus' } />,
|
||||
headline: '$94.16 was added to your October 29, 2019 deposit',
|
||||
body: [],
|
||||
},
|
||||
{
|
||||
date: new Date( 2019, 9, 27, 20, 9 ),
|
||||
icon: <GridIcon icon={ 'checkmark' } className={ 'is-success' } />,
|
||||
headline: 'A payment of $90.00 was successfully authorized',
|
||||
body: [],
|
||||
},
|
||||
]
|
||||
|
||||
<Timeline
|
||||
items={ items }
|
||||
groupBy={ groupByOptions.DAY }
|
||||
orderBy={ orderByOptions.ASC }
|
||||
/>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
Name | Type | Default | Description
|
||||
--- | --- | --- | ---
|
||||
`className` | String | `''` | Additional class names that can be applied for styling purposes
|
||||
`items` | Array | `[]` | An array of items to be displayed on the timeline
|
||||
`orderBy` | String | `'asc'` | How the items should be ordered, either `'asc'` or `'desc'`
|
||||
`groupBy` | String | `'day'` | How the items should be grouped, one of `'day'`, `'week'`, or `'month'`
|
||||
`dateFormat` | String | `'F j, Y'` | PHP date format string used to format dates, see php.net/date
|
||||
`clockFormat` | String | `'g:ia'` | PHP clock format string used to format times, see php.net/date
|
||||
|
||||
|
||||
### `items` structure
|
||||
|
||||
A list of items with properties:
|
||||
|
||||
Name | Type | Default | Description
|
||||
--- | --- | --- | ---
|
||||
`date` | Date | Required | JavaScript Date object set to when this event happened
|
||||
`icon` | Element | Required | The element used to represent the icon for this event
|
||||
`headline` | Element | Required | The element used to represent the title of this event
|
||||
`body` | Array | `[]` | Elements that contain details pertaining to this event
|
||||
`hideTimestamp` | Bool | `false` | Allows the user to hide the timestamp associated with this event
|
||||
|
||||
Icon color can be customized by adding 1 of 3 classes to the icon element: `is-success` (green), `is-warning` (yellow), and `is-error` (red)
|
||||
- If no class is provided the icon will be gray
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import GridIcon from 'gridicons';
|
||||
|
||||
export default [
|
||||
{
|
||||
date: new Date( 2020, 0, 20, 1, 30 ),
|
||||
body: [ <p key={ '1' }>{ 'p element in body' }</p>, 'string in body' ],
|
||||
headline: <p>{ 'p tag in headline' }</p>,
|
||||
icon: (
|
||||
<GridIcon
|
||||
className={ 'is-success' }
|
||||
icon={ 'checkmark' }
|
||||
size={ 16 }
|
||||
/>
|
||||
),
|
||||
hideTimestamp: true,
|
||||
},
|
||||
{
|
||||
date: new Date( 2020, 0, 20, 23, 45 ),
|
||||
body: [],
|
||||
headline: <span>{ 'span in headline' }</span>,
|
||||
icon: (
|
||||
<GridIcon
|
||||
className={ 'is-warning' }
|
||||
icon={ 'refresh' }
|
||||
size={ 16 }
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
date: new Date( 2020, 0, 22, 15, 13 ),
|
||||
body: [ <span key={ '1' }>{ 'span in body' }</span> ],
|
||||
headline: 'string in headline',
|
||||
icon: (
|
||||
<GridIcon className={ 'is-error' } icon={ 'cross' } size={ 16 } />
|
||||
),
|
||||
},
|
||||
{
|
||||
date: new Date( 2020, 0, 17, 1, 45 ),
|
||||
headline: 'undefined body and string headline',
|
||||
icon: <GridIcon icon={ 'cross' } size={ 16 } />,
|
||||
},
|
||||
];
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { format } from '@wordpress/date';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import TimelineGroup from './timeline-group';
|
||||
import { sortByDateUsing, groupItemsUsing } from './util';
|
||||
|
||||
const Timeline = ( props ) => {
|
||||
const {
|
||||
className,
|
||||
items,
|
||||
groupBy,
|
||||
orderBy,
|
||||
dateFormat,
|
||||
clockFormat,
|
||||
} = props;
|
||||
const timelineClassName = classnames( 'woocommerce-timeline', className );
|
||||
|
||||
// Early return in case no data was passed to the component.
|
||||
if ( ! items || items.length === 0 ) {
|
||||
return (
|
||||
<div className={ timelineClassName }>
|
||||
<p className={ 'timeline_no_events' }>
|
||||
{ __( 'No data to display', 'woocommerce-admin' ) }
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const addGroupTitles = ( group ) => {
|
||||
return {
|
||||
...group,
|
||||
title: format( dateFormat, group.date ),
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={ timelineClassName }>
|
||||
<ul>
|
||||
{ items
|
||||
.reduce( groupItemsUsing( groupBy ), [] )
|
||||
.map( addGroupTitles )
|
||||
.sort( sortByDateUsing( orderBy ) )
|
||||
.map( ( group ) => (
|
||||
<TimelineGroup
|
||||
key={ group.date.getTime().toString() }
|
||||
group={ group }
|
||||
orderBy={ orderBy }
|
||||
clockFormat={ clockFormat }
|
||||
/>
|
||||
) ) }
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Timeline.propTypes = {
|
||||
/**
|
||||
* Additional CSS classes.
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
/**
|
||||
* An array of list items.
|
||||
*/
|
||||
items: PropTypes.arrayOf(
|
||||
PropTypes.shape( {
|
||||
/**
|
||||
* Date for the timeline item.
|
||||
*/
|
||||
date: PropTypes.instanceOf( Date ).isRequired,
|
||||
/**
|
||||
* Icon for the Timeline item.
|
||||
*/
|
||||
icon: PropTypes.element.isRequired,
|
||||
/**
|
||||
* Headline displayed for the list item.
|
||||
*/
|
||||
headline: PropTypes.oneOfType( [
|
||||
PropTypes.element,
|
||||
PropTypes.string,
|
||||
] ).isRequired,
|
||||
/**
|
||||
* Body displayed for the list item.
|
||||
*/
|
||||
body: PropTypes.arrayOf(
|
||||
PropTypes.oneOfType( [ PropTypes.element, PropTypes.string ] )
|
||||
),
|
||||
/**
|
||||
* Allows users to toggle the timestamp on or off.
|
||||
*/
|
||||
hideTimestamp: PropTypes.bool,
|
||||
} )
|
||||
).isRequired,
|
||||
/**
|
||||
* Defines how items should be grouped together.
|
||||
*/
|
||||
groupBy: PropTypes.oneOf( [ 'day', 'week', 'month' ] ),
|
||||
/**
|
||||
* Defines how groups should be ordered.
|
||||
*/
|
||||
orderBy: PropTypes.oneOf( [ 'asc', 'desc' ] ),
|
||||
/**
|
||||
* The PHP date format string used to format dates, see php.net/date.
|
||||
*/
|
||||
dateFormat: PropTypes.string,
|
||||
/**
|
||||
* The PHP clock format string used to format times, see php.net/date.
|
||||
*/
|
||||
clockFormat: PropTypes.string,
|
||||
};
|
||||
|
||||
Timeline.defaultProps = {
|
||||
className: '',
|
||||
items: [],
|
||||
groupBy: 'day',
|
||||
orderBy: 'desc',
|
||||
/* translators: PHP date format string used to display dates, see php.net/date. */
|
||||
dateFormat: __( 'F j, Y', 'woocommerce-admin' ),
|
||||
/* translators: PHP clock format string used to display times, see php.net/date. */
|
||||
clockFormat: __( 'g:ia', 'woocommerce-admin' ),
|
||||
};
|
||||
|
||||
export { orderByOptions, groupByOptions } from './util';
|
||||
export default Timeline;
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { date, text } from '@storybook/addon-knobs';
|
||||
import GridIcon from 'gridicons';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Timeline, { orderByOptions } from '../';
|
||||
|
||||
export default {
|
||||
title: 'WooCommerce Admin/components/Timeline',
|
||||
component: Timeline,
|
||||
};
|
||||
|
||||
export const Empty = () => <Timeline />;
|
||||
|
||||
const itemDate = ( label, value ) => {
|
||||
const d = date( label, value );
|
||||
return new Date( d );
|
||||
};
|
||||
|
||||
export const Filled = () => (
|
||||
<Timeline
|
||||
orderBy={ orderByOptions.DESC }
|
||||
items={ [
|
||||
{
|
||||
date: itemDate(
|
||||
'event 1 date',
|
||||
new Date( 2020, 0, 20, 1, 30 )
|
||||
),
|
||||
body: [
|
||||
<p key={ '1' }>
|
||||
{ text( 'event 1, first event', 'p element in body' ) }
|
||||
</p>,
|
||||
text( 'event 1, second event', 'string in body' ),
|
||||
],
|
||||
headline: (
|
||||
<p>{ text( 'event 1, headline', 'p tag in headline' ) }</p>
|
||||
),
|
||||
icon: (
|
||||
<GridIcon
|
||||
className={ 'is-success' }
|
||||
icon={ text( 'event 1 gridicon', 'checkmark' ) }
|
||||
size={ 16 }
|
||||
/>
|
||||
),
|
||||
hideTimestamp: true,
|
||||
},
|
||||
{
|
||||
date: itemDate(
|
||||
'event 2 date',
|
||||
new Date( 2020, 0, 20, 23, 45 )
|
||||
),
|
||||
body: [],
|
||||
headline: (
|
||||
<span>
|
||||
{ text( 'event 2, headline', 'span in headline' ) }
|
||||
</span>
|
||||
),
|
||||
icon: (
|
||||
<GridIcon
|
||||
className={ 'is-warning' }
|
||||
icon={ text( 'event 2 gridicon', 'refresh' ) }
|
||||
size={ 16 }
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
date: itemDate(
|
||||
'event 3 date',
|
||||
new Date( 2020, 0, 22, 15, 13 )
|
||||
),
|
||||
body: [
|
||||
<span key={ '1' }>
|
||||
{ text( 'event 3, second event', 'span in body' ) }
|
||||
</span>,
|
||||
],
|
||||
headline: text( 'event 3, headline', 'string in headline' ),
|
||||
icon: (
|
||||
<GridIcon
|
||||
className={ 'is-error' }
|
||||
icon={ text( 'event 3 gridicon', 'cross' ) }
|
||||
size={ 16 }
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
date: itemDate(
|
||||
'event 4 date',
|
||||
new Date( 2020, 0, 17, 1, 45 )
|
||||
),
|
||||
headline: text(
|
||||
'event 4, headline',
|
||||
'undefined body and string headline'
|
||||
),
|
||||
icon: (
|
||||
<GridIcon
|
||||
icon={ text( 'event 4 gridicon', 'cross' ) }
|
||||
size={ 16 }
|
||||
/>
|
||||
),
|
||||
},
|
||||
] }
|
||||
/>
|
||||
);
|
|
@ -0,0 +1,122 @@
|
|||
|
||||
.woocommerce-timeline {
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
list-style-type: none;
|
||||
|
||||
li {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.woocommerce-timeline-group {
|
||||
.woocommerce-timeline-group__title {
|
||||
color: $studio-gray-90;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
margin: 0 0 $gap 0;
|
||||
|
||||
// Overrides the default `display: block` for p elements in the title.
|
||||
// This is done to prevent soft line breaks from appearing in shorter
|
||||
// titles.
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
hr {
|
||||
float: right;
|
||||
width: calc(100% - #{ $gap-largest });
|
||||
|
||||
margin-bottom: $gap;
|
||||
|
||||
// Color is according to design, we should probably find a suitable color variable.
|
||||
border: 0.5px solid #e3dfe2;
|
||||
}
|
||||
}
|
||||
|
||||
.woocommerce-timeline-item {
|
||||
.woocommerce-timeline-item__top-border {
|
||||
min-height: 16px;
|
||||
border-left: 1px solid $studio-gray-10;
|
||||
margin: 0 $gap-small;
|
||||
}
|
||||
|
||||
.woocommerce-timeline-item__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
color: $studio-gray-80;
|
||||
|
||||
* {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.woocommerce-timeline-item__headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
margin: $gap-smaller 0;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
& > * {
|
||||
padding: 0 $gap;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: $studio-white;
|
||||
padding: $gap-smallest;
|
||||
background: $studio-gray-10;
|
||||
border-radius: 9999px;
|
||||
box-sizing: content-box;
|
||||
// We hard code the size to maintain consistent styling and spacing.
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
&.is-success {
|
||||
background: $valid-green;
|
||||
}
|
||||
|
||||
&.is-warning {
|
||||
background: $notice-yellow;
|
||||
}
|
||||
|
||||
&.is-error {
|
||||
background: $error-red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.woocommerce-timeline-item__timestamp {
|
||||
font-size: 14px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.woocommerce-timeline-item__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
color: $studio-gray-60;
|
||||
|
||||
margin: 0 $gap-small;
|
||||
padding: $gap-smaller $gap-larger;
|
||||
border-left: 1px solid $studio-gray-10;
|
||||
|
||||
// Make sure child elements fit tightly together.
|
||||
* {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide last <hr /> element.
|
||||
.woocommerce-timeline ul :last-child.woocommerce-timeline-group hr:last-child {
|
||||
display: none;
|
||||
}
|
|
@ -0,0 +1,231 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Timeline Empty snapshot 1`] = `
|
||||
<div
|
||||
className="woocommerce-timeline"
|
||||
>
|
||||
<p
|
||||
className="timeline_no_events"
|
||||
>
|
||||
No data to display
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Timeline With data snapshot 1`] = `
|
||||
<div
|
||||
className="woocommerce-timeline"
|
||||
>
|
||||
<ul>
|
||||
<li
|
||||
className="woocommerce-timeline-group"
|
||||
>
|
||||
<p
|
||||
className="woocommerce-timeline-group__title"
|
||||
>
|
||||
January 22, 2020
|
||||
</p>
|
||||
<ul>
|
||||
<li
|
||||
className="woocommerce-timeline-item"
|
||||
>
|
||||
<div
|
||||
className="woocommerce-timeline-item__top-border"
|
||||
/>
|
||||
<div
|
||||
className="woocommerce-timeline-item__title"
|
||||
>
|
||||
<div
|
||||
className="woocommerce-timeline-item__headline"
|
||||
>
|
||||
<svg
|
||||
className="gridicon gridicons-cross is-error"
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
width={16}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M18.36 19.78L12 13.41l-6.36 6.37-1.42-1.42L10.59 12 4.22 5.64l1.42-1.42L12 10.59l6.36-6.36 1.41 1.41L13.41 12l6.36 6.36z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<span>
|
||||
string in headline
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="woocommerce-timeline-item__timestamp"
|
||||
>
|
||||
3:13pm
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="woocommerce-timeline-item__body"
|
||||
>
|
||||
<span>
|
||||
<span>
|
||||
span in body
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<hr />
|
||||
</li>
|
||||
<li
|
||||
className="woocommerce-timeline-group"
|
||||
>
|
||||
<p
|
||||
className="woocommerce-timeline-group__title"
|
||||
>
|
||||
January 20, 2020
|
||||
</p>
|
||||
<ul>
|
||||
<li
|
||||
className="woocommerce-timeline-item"
|
||||
>
|
||||
<div
|
||||
className="woocommerce-timeline-item__top-border"
|
||||
/>
|
||||
<div
|
||||
className="woocommerce-timeline-item__title"
|
||||
>
|
||||
<div
|
||||
className="woocommerce-timeline-item__headline"
|
||||
>
|
||||
<svg
|
||||
className="gridicon gridicons-refresh is-warning"
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
width={16}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M17.91 14c-.478 2.833-2.943 5-5.91 5-3.308 0-6-2.692-6-6s2.692-6 6-6h2.172l-2.086 2.086L13.5 10.5 18 6l-4.5-4.5-1.414 1.414L14.172 5H12c-4.418 0-8 3.582-8 8s3.582 8 8 8c4.08 0 7.438-3.055 7.93-7h-2.02z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<span>
|
||||
<span>
|
||||
span in headline
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="woocommerce-timeline-item__timestamp"
|
||||
>
|
||||
11:45pm
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="woocommerce-timeline-item__body"
|
||||
/>
|
||||
</li>
|
||||
<li
|
||||
className="woocommerce-timeline-item"
|
||||
>
|
||||
<div
|
||||
className="woocommerce-timeline-item__top-border"
|
||||
/>
|
||||
<div
|
||||
className="woocommerce-timeline-item__title"
|
||||
>
|
||||
<div
|
||||
className="woocommerce-timeline-item__headline"
|
||||
>
|
||||
<svg
|
||||
className="gridicon gridicons-checkmark is-success"
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
width={16}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M9 19.414l-6.707-6.707 1.414-1.414L9 16.586 20.293 5.293l1.414 1.414"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<span>
|
||||
<p>
|
||||
p tag in headline
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="woocommerce-timeline-item__timestamp"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="woocommerce-timeline-item__body"
|
||||
>
|
||||
<span>
|
||||
<p>
|
||||
p element in body
|
||||
</p>
|
||||
</span>
|
||||
<span>
|
||||
string in body
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<hr />
|
||||
</li>
|
||||
<li
|
||||
className="woocommerce-timeline-group"
|
||||
>
|
||||
<p
|
||||
className="woocommerce-timeline-group__title"
|
||||
>
|
||||
January 17, 2020
|
||||
</p>
|
||||
<ul>
|
||||
<li
|
||||
className="woocommerce-timeline-item"
|
||||
>
|
||||
<div
|
||||
className="woocommerce-timeline-item__top-border"
|
||||
/>
|
||||
<div
|
||||
className="woocommerce-timeline-item__title"
|
||||
>
|
||||
<div
|
||||
className="woocommerce-timeline-item__headline"
|
||||
>
|
||||
<svg
|
||||
className="gridicon gridicons-cross"
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
width={16}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M18.36 19.78L12 13.41l-6.36 6.37-1.42-1.42L10.59 12 4.22 5.64l1.42-1.42L12 10.59l6.36-6.36 1.41 1.41L13.41 12l6.36 6.36z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<span>
|
||||
undefined body and string headline
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="woocommerce-timeline-item__timestamp"
|
||||
>
|
||||
1:45am
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="woocommerce-timeline-item__body"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<hr />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,189 @@
|
|||
/* eslint-disable jest/no-mocks-import */
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { shallow, mount } from 'enzyme';
|
||||
import renderer from 'react-test-renderer';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Timeline from '..';
|
||||
import mockData from '../__mocks__/timeline-mock-data';
|
||||
import { groupItemsUsing, sortByDateUsing } from '../util.js';
|
||||
|
||||
describe( 'Timeline', () => {
|
||||
test( 'Renders empty correctly', () => {
|
||||
const timeline = shallow( <Timeline /> );
|
||||
expect( timeline.find( '.timeline_no_events' ).length ).toBe( 1 );
|
||||
expect(
|
||||
timeline
|
||||
.find( '.timeline_no_events' )
|
||||
.first()
|
||||
.contains( 'No data to display' )
|
||||
).toBe( true );
|
||||
} );
|
||||
|
||||
test( 'Renders data correctly', () => {
|
||||
const timeline = mount( <Timeline items={ mockData } /> );
|
||||
|
||||
// Ensure correct divs are loaded.
|
||||
expect( timeline.find( '.timeline_no_events' ).length ).toBe( 0 );
|
||||
expect( timeline.find( '.woocommerce-timeline' ).length ).toBe( 1 );
|
||||
|
||||
// Ensure groups have the correct number of items.
|
||||
expect( timeline.find( '.woocommerce-timeline-group' ).length ).toBe(
|
||||
3
|
||||
);
|
||||
expect(
|
||||
timeline
|
||||
.find( '.woocommerce-timeline-group ul' )
|
||||
.at( 0 )
|
||||
.children().length
|
||||
).toBe( 1 );
|
||||
expect(
|
||||
timeline
|
||||
.find( '.woocommerce-timeline-group ul' )
|
||||
.at( 1 )
|
||||
.children().length
|
||||
).toBe( 2 );
|
||||
expect(
|
||||
timeline
|
||||
.find( '.woocommerce-timeline-group ul' )
|
||||
.at( 2 )
|
||||
.children().length
|
||||
).toBe( 1 );
|
||||
|
||||
// Ensure dates are correctly rendered.
|
||||
expect(
|
||||
timeline
|
||||
.find( '.woocommerce-timeline-group__title' )
|
||||
.first()
|
||||
.text()
|
||||
).toBe( 'January 22, 2020' );
|
||||
expect(
|
||||
timeline
|
||||
.find( '.woocommerce-timeline-group__title' )
|
||||
.last()
|
||||
.text()
|
||||
).toBe( 'January 17, 2020' );
|
||||
|
||||
// Ensure the correct number of items is rendered.
|
||||
expect( timeline.find( '.woocommerce-timeline-item' ).length ).toBe(
|
||||
4
|
||||
);
|
||||
|
||||
// Ensure hidden timestamps are actually hidden and vice versa.
|
||||
expect(
|
||||
timeline
|
||||
.find( '.woocommerce-timeline-item__timestamp' )
|
||||
.at( 2 )
|
||||
.text()
|
||||
).toBe( '' );
|
||||
expect(
|
||||
timeline
|
||||
.find( '.woocommerce-timeline-item__timestamp' )
|
||||
.at( 1 )
|
||||
.text()
|
||||
).not.toBe( '' );
|
||||
} );
|
||||
|
||||
test( 'Empty snapshot', () => {
|
||||
const tree = renderer.create( <Timeline /> ).toJSON();
|
||||
expect( tree ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
test( 'With data snapshot', () => {
|
||||
const tree = renderer
|
||||
.create( <Timeline items={ mockData } /> )
|
||||
.toJSON();
|
||||
expect( tree ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
describe( 'Timeline utilities', () => {
|
||||
test( 'Sorts correctly', () => {
|
||||
const jan21 = new Date( 2020, 0, 21 );
|
||||
const jan22 = new Date( 2020, 0, 22 );
|
||||
const jan23 = new Date( 2020, 0, 23 );
|
||||
|
||||
const data = [
|
||||
{ id: 0, date: jan22 },
|
||||
{ id: 1, date: jan21 },
|
||||
{ id: 2, date: jan23 },
|
||||
];
|
||||
const expectedAsc = [
|
||||
{ id: 1, date: jan21 },
|
||||
{ id: 0, date: jan22 },
|
||||
{ id: 2, date: jan23 },
|
||||
];
|
||||
const expectedDesc = [
|
||||
{ id: 2, date: jan23 },
|
||||
{ id: 0, date: jan22 },
|
||||
{ id: 1, date: jan21 },
|
||||
];
|
||||
|
||||
expect( data.sort( sortByDateUsing( 'asc' ) ) ).toStrictEqual(
|
||||
expectedAsc
|
||||
);
|
||||
expect( data.sort( sortByDateUsing( 'desc' ) ) ).toStrictEqual(
|
||||
expectedDesc
|
||||
);
|
||||
} );
|
||||
|
||||
test( "Empty item list doesn't break sort", () => {
|
||||
expect( [].sort( sortByDateUsing( 'asc' ) ) ).toStrictEqual( [] );
|
||||
} );
|
||||
|
||||
test( "Single item doesn't change on sort", () => {
|
||||
const items = [ { date: new Date( 2020, 0, 1 ) } ];
|
||||
expect( items.sort( sortByDateUsing( 'asc' ) ) ).toBe( items );
|
||||
} );
|
||||
|
||||
test( 'Groups correctly', () => {
|
||||
const jan22 = new Date( 2020, 0, 22 );
|
||||
const jan23 = new Date( 2020, 0, 23 );
|
||||
const items = [
|
||||
{ id: 0, date: jan22 },
|
||||
{ id: 1, date: jan23 },
|
||||
{ id: 2, date: jan22 },
|
||||
];
|
||||
const expected = [
|
||||
{
|
||||
date: jan22,
|
||||
items: [
|
||||
{ id: 0, date: jan22 },
|
||||
{ id: 2, date: jan22 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: jan23,
|
||||
items: [ { id: 1, date: jan23 } ],
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
items.reduce( groupItemsUsing( 'days' ), [] )
|
||||
).toStrictEqual( expected );
|
||||
} );
|
||||
|
||||
test( "Empty item list doesn't break grouping", () => {
|
||||
expect( [].reduce( groupItemsUsing( 'days' ), [] ) ).toStrictEqual(
|
||||
[]
|
||||
);
|
||||
} );
|
||||
|
||||
test( 'Single item grouped correctly', () => {
|
||||
const jan22 = new Date( 2020, 0, 22 );
|
||||
const items = [ { id: 0, date: jan22 } ];
|
||||
const expected = [
|
||||
{
|
||||
date: jan22,
|
||||
items: [ { id: 0, date: jan22 } ],
|
||||
},
|
||||
];
|
||||
expect(
|
||||
items.reduce( groupItemsUsing( 'days' ), [] )
|
||||
).toStrictEqual( expected );
|
||||
} );
|
||||
} );
|
||||
} );
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import TimelineItem from './timeline-item';
|
||||
import { sortByDateUsing } from './util';
|
||||
|
||||
const TimelineGroup = ( props ) => {
|
||||
const { group, className, orderBy, clockFormat } = props;
|
||||
const groupClassName = classnames(
|
||||
'woocommerce-timeline-group',
|
||||
className
|
||||
);
|
||||
const itemsToTimlineItem = ( item, itemIndex ) => {
|
||||
const itemKey = group.title + '-' + itemIndex;
|
||||
return (
|
||||
<TimelineItem
|
||||
key={ itemKey }
|
||||
item={ item }
|
||||
clockFormat={ clockFormat }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<li className={ groupClassName }>
|
||||
<p className={ 'woocommerce-timeline-group__title' }>
|
||||
{ group.title }
|
||||
</p>
|
||||
<ul>
|
||||
{ group.items
|
||||
.sort( sortByDateUsing( orderBy ) )
|
||||
.map( itemsToTimlineItem ) }
|
||||
</ul>
|
||||
<hr />
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
TimelineGroup.propTypes = {
|
||||
/**
|
||||
* Additional CSS classes.
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
/**
|
||||
* The group to render.
|
||||
*/
|
||||
group: PropTypes.shape( {
|
||||
/**
|
||||
* The group title.
|
||||
*/
|
||||
title: PropTypes.string,
|
||||
/**
|
||||
* An array of list items.
|
||||
*/
|
||||
items: PropTypes.arrayOf(
|
||||
PropTypes.shape( {
|
||||
/**
|
||||
* Date for the timeline item.
|
||||
*/
|
||||
date: PropTypes.instanceOf( Date ).isRequired,
|
||||
/**
|
||||
* Icon for the Timeline item.
|
||||
*/
|
||||
icon: PropTypes.element.isRequired,
|
||||
/**
|
||||
* Headline displayed for the list item.
|
||||
*/
|
||||
headline: PropTypes.oneOfType( [
|
||||
PropTypes.element,
|
||||
PropTypes.string,
|
||||
] ).isRequired,
|
||||
/**
|
||||
* Body displayed for the list item.
|
||||
*/
|
||||
body: PropTypes.arrayOf(
|
||||
PropTypes.oneOfType( [
|
||||
PropTypes.element,
|
||||
PropTypes.string,
|
||||
] )
|
||||
),
|
||||
/**
|
||||
* Allows users to toggle the timestamp on or off.
|
||||
*/
|
||||
hideTimestamp: PropTypes.bool,
|
||||
} )
|
||||
).isRequired,
|
||||
} ).isRequired,
|
||||
/**
|
||||
* Defines how items should be ordered.
|
||||
*/
|
||||
orderBy: PropTypes.oneOf( [ 'asc', 'desc' ] ),
|
||||
/**
|
||||
* The PHP clock format string used to format times, see php.net/date.
|
||||
*/
|
||||
clockFormat: PropTypes.string,
|
||||
};
|
||||
|
||||
TimelineGroup.defaultProps = {
|
||||
className: '',
|
||||
group: {
|
||||
title: '',
|
||||
items: [],
|
||||
},
|
||||
orderBy: 'desc',
|
||||
};
|
||||
|
||||
export default TimelineGroup;
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { format } from '@wordpress/date';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const TimelineItem = ( props ) => {
|
||||
const { item, className, clockFormat } = props;
|
||||
|
||||
const itemClassName = classnames( 'woocommerce-timeline-item', className );
|
||||
const itemTimeString = format( clockFormat, item.date );
|
||||
|
||||
return (
|
||||
<li className={ itemClassName }>
|
||||
<div className={ 'woocommerce-timeline-item__top-border' }></div>
|
||||
<div className={ 'woocommerce-timeline-item__title' }>
|
||||
<div className={ 'woocommerce-timeline-item__headline' }>
|
||||
{ item.icon }
|
||||
<span>{ item.headline }</span>
|
||||
</div>
|
||||
<span className={ 'woocommerce-timeline-item__timestamp' }>
|
||||
{ item.hideTimestamp || false ? null : itemTimeString }
|
||||
</span>
|
||||
</div>
|
||||
<div className={ 'woocommerce-timeline-item__body' }>
|
||||
{ ( item.body || [] ).map( ( bodyItem, index ) => (
|
||||
<span key={ `timeline-item-body-${ index }` }>
|
||||
{ bodyItem }
|
||||
</span>
|
||||
) ) }
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
TimelineItem.propTypes = {
|
||||
/**
|
||||
* Additional CSS classes.
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
/**
|
||||
* An array of list items.
|
||||
*/
|
||||
item: PropTypes.shape( {
|
||||
/**
|
||||
* Date for the timeline item.
|
||||
*/
|
||||
date: PropTypes.instanceOf( Date ).isRequired,
|
||||
/**
|
||||
* Icon for the Timeline item.
|
||||
*/
|
||||
icon: PropTypes.element.isRequired,
|
||||
/**
|
||||
* Headline displayed for the list item.
|
||||
*/
|
||||
headline: PropTypes.oneOfType( [ PropTypes.element, PropTypes.string ] )
|
||||
.isRequired,
|
||||
/**
|
||||
* Body displayed for the list item.
|
||||
*/
|
||||
body: PropTypes.arrayOf(
|
||||
PropTypes.oneOfType( [ PropTypes.element, PropTypes.string ] )
|
||||
),
|
||||
/**
|
||||
* Allows users to toggle the timestamp on or off.
|
||||
*/
|
||||
hideTimestamp: PropTypes.bool,
|
||||
/**
|
||||
* The PHP clock format string used to format times, see php.net/date.
|
||||
*/
|
||||
clockFormat: PropTypes.string,
|
||||
} ).isRequired,
|
||||
};
|
||||
|
||||
TimelineItem.defaultProps = {
|
||||
className: '',
|
||||
item: {},
|
||||
};
|
||||
|
||||
export default TimelineItem;
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import moment from 'moment';
|
||||
|
||||
const orderByOptions = {
|
||||
ASC: 'asc',
|
||||
DESC: 'desc',
|
||||
};
|
||||
|
||||
const groupByOptions = {
|
||||
DAY: 'day',
|
||||
WEEK: 'week',
|
||||
MONTH: 'month',
|
||||
};
|
||||
|
||||
const sortAscending = ( groupA, groupB ) =>
|
||||
groupA.date.getTime() - groupB.date.getTime();
|
||||
const sortDescending = ( groupA, groupB ) =>
|
||||
groupB.date.getTime() - groupA.date.getTime();
|
||||
|
||||
const sortByDateUsing = ( orderBy ) => {
|
||||
switch ( orderBy ) {
|
||||
case orderByOptions.ASC:
|
||||
return sortAscending;
|
||||
case orderByOptions.DESC:
|
||||
default:
|
||||
return sortDescending;
|
||||
}
|
||||
};
|
||||
|
||||
const groupItemsUsing = ( groupBy ) => ( groups, newItem ) => {
|
||||
// Helper functions defined to make the logic a bit more readable.
|
||||
const hasSameMoment = ( group, item ) => {
|
||||
return moment( group.date ).isSame( moment( item.date ), groupBy );
|
||||
};
|
||||
const groupIndexExists = ( index ) => index >= 0;
|
||||
const groupForItem = groups.findIndex( ( group ) =>
|
||||
hasSameMoment( group, newItem )
|
||||
);
|
||||
|
||||
if ( ! groupIndexExists( groupForItem ) ) {
|
||||
// Create new group for newItem.
|
||||
return [
|
||||
...groups,
|
||||
{
|
||||
date: newItem.date,
|
||||
items: [ newItem ],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
groups[ groupForItem ].items.push( newItem );
|
||||
return groups;
|
||||
};
|
||||
|
||||
export { groupByOptions, groupItemsUsing, orderByOptions, sortByDateUsing };
|
Loading…
Reference in New Issue