-
+
-
+
(visible ? '1' : '0')};
+ transform: translateY(${({ visible }) => (visible ? '0' : '12px')});
+ transition: ${({ withAnimation }) =>
+ withAnimation ? `all 300ms ease-in-out` : 'none'};
+`
+
export const TitleUI = styled(Heading)`
${fontFamily};
line-height: 22px !important;
@@ -76,14 +83,23 @@ export const SubtitleUI = styled(Heading)`
const editorHtmlFontSize = 14
+export const ContentUI = styled.div`
+ margin-top: ${({ withMargin }) => (withMargin ? '20px' : '0')};
+ overflow: auto;
+ padding: 0 20px;
+ display: flex;
+ flex-direction: column;
+
+ & > * + * {
+ margin-top: 20px;
+ }
+`
+
export const BodyUI = styled.div`
- margin-top: ${({ withMargin }) => (withMargin ? '12px' : '0')};
color: ${getColor('charcoal.700')};
font-size: ${editorHtmlFontSize}px;
line-height: 22px;
- padding: 0 20px;
flex: 1 1 100%;
- overflow: auto;
p {
font-size: ${editorHtmlFontSize}px;
@@ -258,7 +274,6 @@ export const BodyUI = styled.div`
`
export const ActionUI = styled('div')`
- margin-bottom: -5px;
margin-top: 20px;
padding: 0 20px;
flex: 0 0 auto;
@@ -282,11 +297,5 @@ export const ImageContainerUI = styled('div')`
display: flex;
justify-content: center;
width: 100%;
- margin-top: 20px;
- padding: 0 10px;
max-height: ${MAX_IMAGE_SIZE}px;
-
- &:last-child {
- margin-bottom: -15px;
- }
`
diff --git a/src/components/MessageCard/MessageCard.jsx b/src/components/MessageCard/MessageCard.jsx
index 699eeeaa2..ac11338c9 100644
--- a/src/components/MessageCard/MessageCard.jsx
+++ b/src/components/MessageCard/MessageCard.jsx
@@ -1,187 +1,104 @@
-import React from 'react'
+import React, { useCallback, useEffect, useRef, useState } from 'react'
import PropTypes from 'prop-types'
import getValidProps from '@helpscout/react-utils/dist/getValidProps'
import MessageCardButton from './MessageCard.Button'
import { classNames } from '../../utilities/classNames'
import { noop } from '../../utilities/other'
-import Animate from '../Animate'
-import {
- ActionUI,
- BodyUI,
- ImageContainerUI,
- ImageUI,
- MAX_IMAGE_SIZE,
- MessageCardUI,
- SubtitleUI,
- TitleUI,
-} from './MessageCard.css'
-import Truncate from '../Truncate'
-
-const sizeWithRatio = (recalculatedSide, otherSide, defaultValue) =>
- // Check if other side is smaller than max size to not recalculate unnecessarily this side as it doesn't need any scaling
- // other condition checks that the image fits the boundaries
- otherSide < MAX_IMAGE_SIZE
- ? defaultValue
- : (recalculatedSide / otherSide) * MAX_IMAGE_SIZE
-
-export class MessageCard extends React.PureComponent {
- static className = 'c-MessageCard'
- static Button = MessageCardButton
-
- getClassName() {
- const { align, className, isMobile, isWithBoxShadow } = this.props
- return classNames(
- MessageCard.className,
- align && `is-align-${align}`,
- className,
- isMobile && 'is-mobile',
- isWithBoxShadow && `is-with-box-shadow`
- )
- }
-
- getTruncatedText(text, limit) {
- return (
-
- {text}
-
- )
- }
-
- renderTitle() {
- const { title } = this.props
- return title ? (
-
- {this.getTruncatedText(title, 110)}
-
- ) : null
- }
-
- renderSubtitle() {
- const { subtitle } = this.props
- return subtitle ? (
-
- {this.getTruncatedText(subtitle, 110)}
-
- ) : null
- }
-
- renderBody() {
- const { onBodyClick, title, subtitle } = this.props
- let { body } = this.props
- const withMargin = title || subtitle
-
- // if there is no html in the string, transform new line to paragraph
- if (body && !/<\/?[a-z][\s\S]*>/i.test(body)) {
- body = body.split('\n').join('
')
- }
-
- return body ? (
-
-
-
- ) : null
- }
-
- renderImage() {
- const { image } = this.props
-
- if (!image) {
- return null
- }
-
- const { height, width } = this.calculateSize(image)
-
- return (
-
-
-
- )
- }
-
- // Calculate size of image to keep the original aspect ratio, but fit within 278x278 square for image
- calculateSize = image => {
- if (!image.width || !image.height) {
- return {}
- }
- const width = parseInt(image.width)
- const height = parseInt(image.height)
-
- // Not necessary to recalculate if it fits within boundaries
- if (width < MAX_IMAGE_SIZE && height < MAX_IMAGE_SIZE) {
- return { width, height }
- }
-
- if (width > height) {
- return {
- height: sizeWithRatio(height, width, height),
- width: Math.min(width, MAX_IMAGE_SIZE),
- }
- } else {
- return {
- width: sizeWithRatio(width, height, MAX_IMAGE_SIZE),
- height: Math.min(height, MAX_IMAGE_SIZE),
+import { MessageCardUI, MessageCardWrapperUI } from './MessageCard.css'
+import { MessageCardTitle } from './components/MessageCard.Title'
+import { MessageCardSubtitle } from './components/MessageCard.Subtitle'
+import { MessageCardImage } from './components/MessageCard.Image'
+import { MessageCardAction } from './components/MessageCard.Action'
+import { MessageCardBody } from './components/MessageCard.Body'
+import { MessageCardContent } from './components/MessageCard.Content'
+
+export const MessageCard = React.memo(
+ React.forwardRef(
+ (
+ {
+ onShow,
+ in: inProp,
+ image,
+ action,
+ animationDuration,
+ animationEasing,
+ animationSequence,
+ children,
+ title,
+ subtitle,
+ onBodyClick,
+ body,
+ withAnimation,
+ className,
+ align,
+ isMobile,
+ isWithBoxShadow,
+ ...rest
+ },
+ ref
+ ) => {
+ const [visible, setVisible] = useState(false)
+ const isShown = useRef(false)
+
+ const hasImage = useCallback(() => image && image.url, [image])
+
+ useEffect(() => {
+ if (inProp && !hasImage() && !isShown.current) {
+ makeMessageVisible()
+ }
+ }, [inProp, hasImage])
+
+ useEffect(() => {
+ if (visible && !isShown.current) {
+ onShow()
+ isShown.current = true
+ }
+ }, [visible, onShow])
+
+ const makeMessageVisible = () => {
+ setTimeout(() => {
+ setVisible(true)
+ }, 0)
}
- }
- }
-
- renderAction() {
- const { action } = this.props
- return action ? (
-
{action()}
- ) : null
- }
- render() {
- const {
- action,
- animationDuration,
- animationEasing,
- animationSequence,
- children,
- innerRef,
- in: inProp,
- title,
- ...rest
- } = this.props
+ const getClassName = () => {
+ return classNames(
+ MessageCard.className,
+ align && `is-align-${align}`,
+ className,
+ isMobile && 'is-mobile',
+ isWithBoxShadow && `is-with-box-shadow`
+ )
+ }
- return (
-
-
- {this.renderTitle()}
- {this.renderSubtitle()}
- {this.renderBody()}
- {this.renderImage()}
- {children}
- {this.renderAction()}
-
-
- )
- }
-}
+
+
+
+
+
+
+ {children}
+
+
+
+
+ ) : null
+ }
+ )
+)
MessageCard.defaultProps = {
align: 'right',
@@ -192,6 +109,8 @@ MessageCard.defaultProps = {
isMobile: false,
isWithBoxShadow: true,
onBodyClick: noop,
+ onShow: noop,
+ withAnimation: false,
}
MessageCard.propTypes = {
@@ -209,7 +128,10 @@ MessageCard.propTypes = {
body: PropTypes.string,
/** The className of the component. */
className: PropTypes.string,
- innerRef: PropTypes.func,
+ ref: PropTypes.oneOfType([
+ PropTypes.func,
+ PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
+ ]),
/** Programatically triggering the animation. */
in: PropTypes.bool,
/** Adds mobile styles */
@@ -231,6 +153,13 @@ MessageCard.propTypes = {
}),
/** Data attr for Cypress tests. */
'data-cy': PropTypes.string,
+ /** Callback invoked when the MessageCard is show to the user. */
+ onShow: PropTypes.func,
+ /** Enable animations when showing the Message. */
+ withAnimation: PropTypes.bool,
}
+MessageCard.className = 'c-MessageCard'
+MessageCard.Button = MessageCardButton
+
export default MessageCard
diff --git a/src/components/MessageCard/MessageCard.stories.mdx b/src/components/MessageCard/MessageCard.stories.mdx
index 370828acb..3c878f016 100644
--- a/src/components/MessageCard/MessageCard.stories.mdx
+++ b/src/components/MessageCard/MessageCard.stories.mdx
@@ -24,12 +24,11 @@ This component renders a Message Card Notification with (optional) Title, Subtit
+
+# With image and action
+
+
diff --git a/src/components/MessageCard/MessageCard.test.js b/src/components/MessageCard/MessageCard.test.js
index c46b8c5c0..d94dd3baf 100644
--- a/src/components/MessageCard/MessageCard.test.js
+++ b/src/components/MessageCard/MessageCard.test.js
@@ -1,16 +1,15 @@
import React from 'react'
import { mount, render } from 'enzyme'
-import { MessageCard } from './MessageCard'
+import MessageCard from './MessageCard'
import {
TitleUI,
SubtitleUI,
BodyUI,
ActionUI,
ImageUI,
- ImageContainerUI,
} from './MessageCard.css'
-import { Animate } from '../index'
import { MessageCardButton as Button } from './MessageCard.Button'
+import { act } from 'react-dom/test-utils'
describe('className', () => {
test('Has default className', () => {
@@ -61,27 +60,106 @@ describe('Align', () => {
})
})
-describe('Animation', () => {
- test('Can customize animationSequence', () => {
- const wrapper = mount(
)
- const o = wrapper.find(Animate)
+describe('Visibility', () => {
+ jest.useFakeTimers()
+
+ test('Should be visible by default if there is no image', () => {
+ const onShowSpy = jest.fn()
+ const wrapper = mount(
)
+
+ expect(cardWrapperVisible(wrapper)).toEqual(false)
+ expect(onShowSpy).not.toHaveBeenCalled()
- expect(o.prop('sequence')).toBe('scale')
+ act(() => {
+ jest.runAllTimers()
+ wrapper.update()
+ })
+
+ expect(cardWrapperVisible(wrapper)).toEqual(true)
+ expect(onShowSpy).toHaveBeenCalled()
})
- test('Can customize animationEasing', () => {
- const wrapper = mount(
)
- const o = wrapper.find(Animate)
+ test('Should not be visible by default if there is an image, but become visible when image loads', () => {
+ const onShowSpy = jest.fn()
+ const wrapper = mount(
+
+ )
+
+ expect(cardWrapperVisible(wrapper)).toEqual(false)
+ expect(onShowSpy).not.toHaveBeenCalled()
- expect(o.prop('easing')).toBe('linear')
+ jest.runAllTimers()
+ wrapper.update()
+
+ expect(cardWrapperVisible(wrapper)).toEqual(false)
+ expect(onShowSpy).not.toHaveBeenCalled()
+
+ wrapper.find('img').simulate('load')
+
+ act(() => {
+ jest.runAllTimers()
+ wrapper.update()
+ })
+
+ expect(wrapper.find('img')).toHaveLength(1)
+ expect(cardWrapperVisible(wrapper)).toEqual(true)
+ expect(onShowSpy).toHaveBeenCalled()
})
- test('Can customize animationDuration', () => {
- const wrapper = mount(
)
- const o = wrapper.find(Animate)
+ test('Should become visible without image if image fails to load', () => {
+ const onShowSpy = jest.fn()
+ const wrapper = mount(
+
+ )
- expect(o.prop('duration')).toBe(123)
+ expect(cardWrapperVisible(wrapper)).toEqual(false)
+ expect(onShowSpy).not.toHaveBeenCalled()
+
+ jest.runAllTimers()
+ wrapper.update()
+
+ expect(cardWrapperVisible(wrapper)).toEqual(false)
+ expect(onShowSpy).not.toHaveBeenCalled()
+
+ wrapper.find('img').simulate('error')
+
+ act(() => {
+ jest.runAllTimers()
+ wrapper.update()
+ })
+
+ expect(wrapper.find('img')).toHaveLength(0)
+ expect(cardWrapperVisible(wrapper)).toEqual(true)
+ expect(onShowSpy).toHaveBeenCalled()
+ })
+
+ function cardWrapperVisible(wrapper) {
+ return wrapper.find('.c-MessageCardWrapper').at(0).prop('visible')
+ }
+})
+
+describe('Animation', () => {
+ test('Should have no animation by default', () => {
+ const wrapper = mount(
)
+
+ expect(cardWrapperAnimation(wrapper)).toEqual(false)
})
+
+ test('Should have animation if withAnimation is true', () => {
+ const wrapper = mount(
)
+
+ expect(cardWrapperAnimation(wrapper)).toEqual(true)
+ })
+
+ function cardWrapperAnimation(wrapper) {
+ return wrapper.find('.c-MessageCardWrapper').at(0).prop('withAnimation')
+ }
})
describe('Body', () => {
@@ -208,8 +286,8 @@ describe('image', () => {
)
const image = wrapper.find(ImageUI)
- expect(image.prop('height')).toEqual('104.25px')
- expect(image.prop('width')).toEqual('278px')
+ expect(image.prop('height')).toEqual('96.75px')
+ expect(image.prop('width')).toEqual('258px')
})
test('Scales size of image when larger than fits and height is bigger', () => {
@@ -224,8 +302,8 @@ describe('image', () => {
)
const image = wrapper.find(ImageUI)
- expect(image.prop('height')).toEqual('278px')
- expect(image.prop('width')).toEqual('104.25px')
+ expect(image.prop('height')).toEqual('258px')
+ expect(image.prop('width')).toEqual('96.75px')
})
test('Sets default size of image when not provided', () => {
diff --git a/src/components/MessageCard/MessageCard.utils.js b/src/components/MessageCard/MessageCard.utils.js
new file mode 100644
index 000000000..21bd37a5c
--- /dev/null
+++ b/src/components/MessageCard/MessageCard.utils.js
@@ -0,0 +1,55 @@
+import { MAX_IMAGE_SIZE } from './MessageCard.css'
+import Truncate from '../Truncate'
+import React from 'react'
+
+const sizeWithRatio = (recalculatedSide, otherSide, defaultValue) =>
+ // Check if other side is smaller than max size to not recalculate unnecessarily this side as it doesn't need any scaling
+ // other condition checks that the image fits the boundaries
+ otherSide < MAX_IMAGE_SIZE
+ ? defaultValue
+ : (recalculatedSide / otherSide) * MAX_IMAGE_SIZE
+
+/**
+ @param text text to truncate
+ @param limit limit to truncate to
+ @return element with text truncated to the given limit.
+ */
+export const getTruncatedText = (text, limit) => {
+ return (
+
+ {text}
+
+ )
+}
+
+/**
+ Calculate size of image to keep the original aspect ratio, but fit within MAX_IMAGE_SIZExMAX_IMAGE_SIZE square for image
+
+ @param image image to calculate size for. Must have width and height defined.
+
+ @return size Object of type {width: number, height: number} that defines recalculated image's size. Empty object in case of no width or height in parameter image.
+ */
+export const calculateSize = image => {
+ if (!image.width || !image.height) {
+ return {}
+ }
+ const width = parseInt(image.width)
+ const height = parseInt(image.height)
+
+ // Not necessary to recalculate if it fits within boundaries
+ if (width < MAX_IMAGE_SIZE && height < MAX_IMAGE_SIZE) {
+ return { width, height }
+ }
+
+ if (width > height) {
+ return {
+ height: sizeWithRatio(height, width, height),
+ width: Math.min(width, MAX_IMAGE_SIZE),
+ }
+ } else {
+ return {
+ width: sizeWithRatio(width, height, MAX_IMAGE_SIZE),
+ height: Math.min(height, MAX_IMAGE_SIZE),
+ }
+ }
+}
diff --git a/src/components/MessageCard/components/MessageCard.Action.jsx b/src/components/MessageCard/components/MessageCard.Action.jsx
new file mode 100644
index 000000000..a798b5a1b
--- /dev/null
+++ b/src/components/MessageCard/components/MessageCard.Action.jsx
@@ -0,0 +1,14 @@
+import { ActionUI } from '../MessageCard.css'
+import React from 'react'
+import PropTypes from 'prop-types'
+
+export const MessageCardAction = ({ action }) => {
+ return action ? (
+
{action()}
+ ) : null
+}
+
+MessageCardAction.propTypes = {
+ /** Action to be called */
+ action: PropTypes.func,
+}
diff --git a/src/components/MessageCard/components/MessageCard.Body.jsx b/src/components/MessageCard/components/MessageCard.Body.jsx
new file mode 100644
index 000000000..bb2acf842
--- /dev/null
+++ b/src/components/MessageCard/components/MessageCard.Body.jsx
@@ -0,0 +1,39 @@
+import { BodyUI } from '../MessageCard.css'
+import React from 'react'
+import PropTypes from 'prop-types'
+import { noop } from '../../../utilities/other'
+
+export const MessageCardBody = ({ withMargin, body, onClick }) => {
+ const getBodyToRender = () => {
+ // if there is no html in the string, transform new line to paragraph
+ if (body && !/<\/?[a-z][\s\S]*>/i.test(body)) {
+ return body.split('\n').join('
')
+ }
+ return body
+ }
+
+ const bodyToRender = getBodyToRender()
+
+ return bodyToRender ? (
+
+
+
+ ) : null
+}
+
+MessageCardBody.propTypes = {
+ /** Body content */
+ body: PropTypes.string,
+ /** Indicate if should add margin above the body */
+ withMargin: PropTypes.string,
+ /** Callback when body clicked */
+ onClick: PropTypes.func,
+}
+
+MessageCardBody.defaultProps = {
+ onClick: noop,
+}
diff --git a/src/components/MessageCard/components/MessageCard.Content.jsx b/src/components/MessageCard/components/MessageCard.Content.jsx
new file mode 100644
index 000000000..4cbfa6fbe
--- /dev/null
+++ b/src/components/MessageCard/components/MessageCard.Content.jsx
@@ -0,0 +1,18 @@
+import PropTypes from 'prop-types'
+import { ContentUI } from '../MessageCard.css'
+import React from 'react'
+
+export const MessageCardContent = ({ children, withMargin, render }) => {
+ if (!render) {
+ return null
+ }
+ return
{children}
+}
+
+MessageCardContent.propTypes = {
+ children: PropTypes.any,
+ /** Indicates if should add margin to the top */
+ withMargin: PropTypes.bool,
+ /** Indicates if should render the content at all */
+ render: PropTypes.bool,
+}
diff --git a/src/components/MessageCard/components/MessageCard.Image.jsx b/src/components/MessageCard/components/MessageCard.Image.jsx
new file mode 100644
index 000000000..be677e422
--- /dev/null
+++ b/src/components/MessageCard/components/MessageCard.Image.jsx
@@ -0,0 +1,48 @@
+import { calculateSize } from '../MessageCard.utils'
+import { ImageContainerUI, ImageUI } from '../MessageCard.css'
+import React, { useState } from 'react'
+import PropTypes from 'prop-types'
+import { noop } from '../../../utilities/other'
+
+export const MessageCardImage = ({ image, onLoad }) => {
+ const [imageError, setImageError] = useState(false)
+ const onImageError = () => {
+ setImageError(true)
+ onLoad()
+ }
+
+ if (!image || imageError) {
+ return null
+ }
+
+ const { height, width } = calculateSize(image)
+
+ return (
+
+
+
+ )
+}
+
+MessageCardImage.propTypes = {
+ /** Image to render */
+ image: PropTypes.shape({
+ url: PropTypes.string.isRequired,
+ altText: PropTypes.string,
+ width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ }),
+ /** Callback when image loaded */
+ onLoad: PropTypes.func,
+}
+
+MessageCardImage.defaultProps = {
+ onLoad: noop,
+}
diff --git a/src/components/MessageCard/components/MessageCard.Subtitle.jsx b/src/components/MessageCard/components/MessageCard.Subtitle.jsx
new file mode 100644
index 000000000..3e019d203
--- /dev/null
+++ b/src/components/MessageCard/components/MessageCard.Subtitle.jsx
@@ -0,0 +1,17 @@
+import { SubtitleUI } from '../MessageCard.css'
+import { getTruncatedText } from '../MessageCard.utils'
+import React from 'react'
+import PropTypes from 'prop-types'
+
+export const MessageCardSubtitle = ({ subtitle }) => {
+ return subtitle ? (
+
+ {getTruncatedText(subtitle, 110)}
+
+ ) : null
+}
+
+MessageCardSubtitle.propTypes = {
+ /** Subtitle of a Message */
+ subtitle: PropTypes.string,
+}
diff --git a/src/components/MessageCard/components/MessageCard.Title.jsx b/src/components/MessageCard/components/MessageCard.Title.jsx
new file mode 100644
index 000000000..6cb7068c0
--- /dev/null
+++ b/src/components/MessageCard/components/MessageCard.Title.jsx
@@ -0,0 +1,17 @@
+import { TitleUI } from '../MessageCard.css'
+import { getTruncatedText } from '../MessageCard.utils'
+import React from 'react'
+import PropTypes from 'prop-types'
+
+export const MessageCardTitle = ({ title }) => {
+ return title ? (
+
+ {getTruncatedText(title, 110)}
+
+ ) : null
+}
+
+MessageCardTitle.propTypes = {
+ /** Title of a Message */
+ title: PropTypes.string,
+}
diff --git a/src/utilities/pkg.js b/src/utilities/pkg.js
index b1f56b8ba..7e1971154 100644
--- a/src/utilities/pkg.js
+++ b/src/utilities/pkg.js
@@ -1,3 +1,3 @@
export default {
- version: '3.13.0',
+ version: '3.14.0',
}