From f88293da07895dd3fcd95ab8fbfdf98fb82b539f Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Thu, 9 Nov 2017 13:28:12 +0200 Subject: [PATCH 1/2] Add message type and test helper --- src/util/propTypes.js | 45 ++++++++++++++++++++++++++++--------------- src/util/test-data.js | 13 +++++++++++++ 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/src/util/propTypes.js b/src/util/propTypes.js index 1935415d..b983b275 100644 --- a/src/util/propTypes.js +++ b/src/util/propTypes.js @@ -62,6 +62,22 @@ export const place = shape({ country: string, // country code, e.g. FI, US }); +// Denormalised image object +export const image = shape({ + id: uuid.isRequired, + type: value('image').isRequired, + attributes: shape({ + sizes: arrayOf( + shape({ + width: number.isRequired, + height: number.isRequired, + name: string.isRequired, + url: string.isRequired, + }) + ).isRequired, + }), +}); + // Denormalised user object export const currentUser = shape({ id: uuid.isRequired, @@ -79,6 +95,7 @@ export const currentUser = shape({ }).isRequired, stripeConnected: bool.isRequired, }), + profileImage: image, }); // Denormalised user object @@ -93,22 +110,7 @@ export const user = shape({ bio: string, }), }), -}); - -// Denormalised image object -export const image = shape({ - id: uuid.isRequired, - type: value('image').isRequired, - attributes: shape({ - sizes: arrayOf( - shape({ - width: number.isRequired, - height: number.isRequired, - name: string.isRequired, - url: string.isRequired, - }) - ).isRequired, - }), + profileImage: image, }); // Denormalised listing object @@ -212,6 +214,17 @@ export const transaction = shape({ provider: user, }); +// Denormalised transaction message +export const message = shape({ + id: uuid.isRequired, + type: value('message').isRequired, + attributes: shape({ + at: instanceOf(Date).isRequired, + content: string.isRequired, + }).isRequired, + sender: user, +}); + // Pagination information in the response meta export const pagination = shape({ page: number.isRequired, diff --git a/src/util/test-data.js b/src/util/test-data.js index 5c682b38..5c329e3f 100644 --- a/src/util/test-data.js +++ b/src/util/test-data.js @@ -131,6 +131,19 @@ export const createTransaction = options => { }; }; +export const createMessage = (id, attributes = {}, includes = {}) => { + return { + id: new UUID(id), + type: 'message', + attributes: { + at: new Date(Date.UTC(2017, 10, 9, 8, 12)), + content: `Message ${id}\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`, + ...attributes, + }, + ...includes, + }; +}; + // Default config for currency formatting in tests and examples. export const currencyConfig = { style: 'currency', From b362e3005405562ada0145eec97eef3665d46165 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Thu, 9 Nov 2017 13:29:29 +0200 Subject: [PATCH 2/2] Add Messages component --- src/components/Messages/Messages.css | 80 ++++++++++++++++ src/components/Messages/Messages.example.js | 35 +++++++ src/components/Messages/Messages.js | 92 +++++++++++++++++++ src/components/Messages/Messages.test.js | 19 ++++ .../__snapshots__/Messages.test.js.snap | 63 +++++++++++++ src/components/index.js | 5 +- src/examples.js | 2 + src/marketplaceFonts.css | 21 +++++ src/translations/en.json | 1 + 9 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 src/components/Messages/Messages.css create mode 100644 src/components/Messages/Messages.example.js create mode 100644 src/components/Messages/Messages.js create mode 100644 src/components/Messages/Messages.test.js create mode 100644 src/components/Messages/__snapshots__/Messages.test.js.snap diff --git a/src/components/Messages/Messages.css b/src/components/Messages/Messages.css new file mode 100644 index 00000000..c00e5534 --- /dev/null +++ b/src/components/Messages/Messages.css @@ -0,0 +1,80 @@ +@import '../../marketplace.css'; + +.root { + margin: 0; + + /* Clearfix */ + width: 100%; + overflow: auto; +} + +.messageItem { + margin-bottom: 35px; + + @media (--viewportMedium) { + margin-bottom: 40px; + } + + &:last-of-type { + margin-bottom: 0; + } +} + +.message, +.ownMessage { + /* Preserve newlines, but collapse other whitespace */ + white-space: pre-line; + max-width: 433px; +} + +.message { + display: flex; + flex-direction: row; +} + +.ownMessage { + float: right; +} + +.avatar { + flex-shrink: 0; + margin: 9px 12px 0 0; +} + +.messageContent, +.ownMessageContent { + @apply --marketplaceMessageFontStyles; + + margin: 0; + padding: 15px 14px 11px 14px; + border-radius: 8px; + box-shadow: var(--boxShadow); + + @media (--viewportMedium) { + margin: 0; + } +} + +.messageContent { + flex: 1; +} + +.ownMessageContent { + background-color: var(--marketplaceColor); + color: var(--matterColorLight); +} + +.messageDate, +.ownMessageDate { + @apply --marketplaceMessageDateFontStyles; + margin: 11px 0 0 0; + color: var(--matterColorAnti); + + @media (--viewportMedium) { + margin: 14px 0 0 0; + } +} + +.ownMessageDate { + text-align: right; +} diff --git a/src/components/Messages/Messages.example.js b/src/components/Messages/Messages.example.js new file mode 100644 index 00000000..69fed49e --- /dev/null +++ b/src/components/Messages/Messages.example.js @@ -0,0 +1,35 @@ +import { createUser, createCurrentUser, createMessage } from '../../util/test-data'; +import Messages from './Messages'; + +export const Empty = { + component: Messages, + props: { + messages: [], + currentUser: null, + }, + group: 'messages', +}; + +export const WithoutCurrentUser = { + component: Messages, + props: { + messages: [ + createMessage('msg1', {}, { sender: createUser('user1') }), + createMessage('msg2', {}, { sender: createUser('user2') }), + ], + currentUser: null, + }, + group: 'messages', +}; + +export const WithCurrentUser = { + component: Messages, + props: { + messages: [ + createMessage('msg1', {}, { sender: createUser('user1') }), + createMessage('msg2', {}, { sender: createUser('user2') }), + ], + currentUser: createCurrentUser('user2'), + }, + group: 'messages', +}; diff --git a/src/components/Messages/Messages.js b/src/components/Messages/Messages.js new file mode 100644 index 00000000..cf622cba --- /dev/null +++ b/src/components/Messages/Messages.js @@ -0,0 +1,92 @@ +import React from 'react'; +import { string, arrayOf } from 'prop-types'; +import { injectIntl, intlShape } from 'react-intl'; +import classNames from 'classnames'; +import { Avatar } from '../../components'; +import { formatDate } from '../../util/dates'; +import * as propTypes from '../../util/propTypes'; + +import css from './Messages.css'; + +const Message = props => { + const { message, intl } = props; + const todayString = intl.formatMessage({ id: 'Messages.today' }); + return ( +
+ +
+

{message.attributes.content}

+

{formatDate(intl, todayString, message.attributes.at)}

+
+
+ ); +}; + +Message.propTypes = { + message: propTypes.message.isRequired, + intl: intlShape.isRequired, +}; + +const OwnMessage = props => { + const { message, intl } = props; + const todayString = intl.formatMessage({ id: 'Messages.today' }); + return ( +
+

{message.attributes.content}

+

{formatDate(intl, todayString, message.attributes.at)}

+
+ ); +}; + +OwnMessage.propTypes = { + message: propTypes.message.isRequired, + intl: intlShape.isRequired, +}; + +export const MessagesComponent = props => { + const { rootClassName, className, messages, currentUser, intl } = props; + const classes = classNames(rootClassName || css.root, className); + + const msg = message => { + const isOwnMessage = + message.sender && + message.sender.id && + currentUser && + currentUser.id && + message.sender.id.uuid === currentUser.id.uuid; + if (isOwnMessage) { + return ; + } + return ; + }; + + return ( +
    + {messages.map(m => ( +
  • + {msg(m)} +
  • + ))} +
+ ); +}; + +MessagesComponent.defaultProps = { + rootClassName: null, + className: null, +}; + +MessagesComponent.propTypes = { + rootClassName: string, + className: string, + + messages: arrayOf(propTypes.message), + currentUser: propTypes.currentUser, + + // from injectIntl + intl: intlShape.isRequired, +}; + +const Messages = injectIntl(MessagesComponent); + +export default Messages; diff --git a/src/components/Messages/Messages.test.js b/src/components/Messages/Messages.test.js new file mode 100644 index 00000000..d8ee9f6a --- /dev/null +++ b/src/components/Messages/Messages.test.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { renderDeep } from '../../util/test-helpers'; +import { fakeIntl, createUser, createCurrentUser, createMessage } from '../../util/test-data'; +import { MessagesComponent } from './Messages'; + +describe('Messages', () => { + it('matches snapshot', () => { + const props = { + messages: [ + createMessage('msg1', {}, { sender: createUser('user1') }), + createMessage('msg2', {}, { sender: createUser('user2') }), + ], + currentUser: createCurrentUser('user2'), + intl: fakeIntl, + }; + const tree = renderDeep(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/components/Messages/__snapshots__/Messages.test.js.snap b/src/components/Messages/__snapshots__/Messages.test.js.snap new file mode 100644 index 00000000..b054976d --- /dev/null +++ b/src/components/Messages/__snapshots__/Messages.test.js.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Messages matches snapshot 1`] = ` +
    +
  • +
    + + + TT + + +
    +

    + Message msg1 + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +

    +

    + 2017-11-09, 08:12 +

    +
    +
    +
  • +
  • +
    +

    + Message msg2 + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +

    +

    + 2017-11-09, 08:12 +

    +
    +
  • +
+`; diff --git a/src/components/index.js b/src/components/index.js index df575f41..20493614 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -49,10 +49,10 @@ export { default as ImageCarousel } from './ImageCarousel/ImageCarousel'; export { default as ImageFromFile } from './ImageFromFile/ImageFromFile'; export { default as LayoutSideNavigation } from './LayoutSideNavigation/LayoutSideNavigation'; export { default as LayoutSingleColumn } from './LayoutSingleColumn/LayoutSingleColumn'; -export { default as LayoutWrapperTopbar } from './LayoutWrapperTopbar/LayoutWrapperTopbar'; +export { default as LayoutWrapperFooter } from './LayoutWrapperFooter/LayoutWrapperFooter'; export { default as LayoutWrapperMain } from './LayoutWrapperMain/LayoutWrapperMain'; export { default as LayoutWrapperSideNav } from './LayoutWrapperSideNav/LayoutWrapperSideNav'; -export { default as LayoutWrapperFooter } from './LayoutWrapperFooter/LayoutWrapperFooter'; +export { default as LayoutWrapperTopbar } from './LayoutWrapperTopbar/LayoutWrapperTopbar'; export { default as ListingCard } from './ListingCard/ListingCard'; export { default as LocationAutocompleteInput, @@ -66,6 +66,7 @@ export { default as Menu } from './Menu/Menu'; export { default as MenuContent } from './MenuContent/MenuContent'; export { default as MenuItem } from './MenuItem/MenuItem'; export { default as MenuLabel } from './MenuLabel/MenuLabel'; +export { default as Messages } from './Messages/Messages'; export { default as Modal } from './Modal/Modal'; export { default as ModalInMobile } from './ModalInMobile/ModalInMobile'; export { default as NamedLink } from './NamedLink/NamedLink'; diff --git a/src/examples.js b/src/examples.js index 6209d7dc..00e62fda 100644 --- a/src/examples.js +++ b/src/examples.js @@ -29,6 +29,7 @@ import * as LocationAutocompleteInput from './components/LocationAutocompleteInp import * as ManageListingCard from './components/ManageListingCard/ManageListingCard.example'; import * as Map from './components/Map/Map.example'; import * as Menu from './components/Menu/Menu.example'; +import * as Messages from './components/Messages/Messages.example'; import * as Modal from './components/Modal/Modal.example'; import * as ModalInMobile from './components/ModalInMobile/ModalInMobile.example'; import * as NamedLink from './components/NamedLink/NamedLink.example'; @@ -97,6 +98,7 @@ export { ManageListingCard, Map, Menu, + Messages, Modal, ModalInMobile, NamedLink, diff --git a/src/marketplaceFonts.css b/src/marketplaceFonts.css index 68cd7df2..5ef40a57 100644 --- a/src/marketplaceFonts.css +++ b/src/marketplaceFonts.css @@ -210,6 +210,27 @@ } } + --marketplaceMessageFontStyles { + font-family: 'sofiapro', Helvetica, Arial, sans-serif; + font-weight: var(--fontWeightRegular); + font-size: 16px; + line-height: 24px; + } + + --marketplaceMessageDateFontStyles { + font-family: 'sofiapro', Helvetica, Arial, sans-serif; + font-weight: var(--fontWeightRegular); + font-size: 13px; + line-height: 18px; + letter-spacing: -0.1px; + + @media (--viewportMedium) { + font-weight: var(--fontWeightMedium); + font-size: 14px; + line-height: 24px; + } + } + /* ================ Tabbed navigation font styles ================ */ --marketplaceTabNavFontStyles { diff --git a/src/translations/en.json b/src/translations/en.json index 19f76899..8a95a55d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -248,6 +248,7 @@ "ManageListingsPage.youHaveListings": "You have {count} {count, plural, one {listing} other {listings}}", "ManageListingsPage.yourListings": "Your listings", "MapPriceMarker.unsupportedPrice": "({currency})", + "Messages.today": "Today", "Modal.close": "CLOSE", "Modal.closeModal": "Close modal", "NotFoundPage.description": "We can't find the page or the sauna you're looking for. Make sure you've typed the URL correctly, of try searching Saunatime.",