diff --git a/src/components/ListingCard/ListingCard.css b/src/components/ListingCard/ListingCard.css index 37c5c98d..3e7522e3 100644 --- a/src/components/ListingCard/ListingCard.css +++ b/src/components/ListingCard/ListingCard.css @@ -120,3 +120,11 @@ margin-bottom: 0; } } + +.longWord { + /* fallback option */ + word-break: break-all; + /* use break-word if available */ + word-break: break-word; + hyphens: auto; +} diff --git a/src/components/ListingCard/ListingCard.js b/src/components/ListingCard/ListingCard.js index 0693efbe..66d69918 100644 --- a/src/components/ListingCard/ListingCard.js +++ b/src/components/ListingCard/ListingCard.js @@ -6,12 +6,13 @@ import { NamedLink, ResponsiveImage } from '../../components'; import { propTypes } from '../../util/types'; import { formatMoney } from '../../util/currency'; import { ensureListing, ensureUser } from '../../util/data'; +import { richText } from '../../util/richText'; import { createSlug } from '../../util/urlHelpers'; import config from '../../config'; import css from './ListingCard.css'; -const MAX_LENGTH_FOR_WORDS_IN_TITLE = 10; +const MIN_LENGTH_FOR_LONG_WORDS = 10; const priceData = (price, intl) => { if (price && price.currency === config.currency) { @@ -32,23 +33,6 @@ const priceData = (price, intl) => { return {}; }; -// Cards are not fixed sizes - So, long words in title make flexboxed items to grow too big. -// 1. We split title to an array of words and spaces. -// "foo bar".split(/([^\s]+)/gi) => ["", "foo", " ", "bar", ""] -// 2. Then we break long words by adding a '' with word-break: 'break-all'; -const formatTitle = (title, maxLength) => { - const nonWhiteSpaceSequence = /([^\s]+)/gi; - return title.split(nonWhiteSpaceSequence).map((word, index) => { - return word.length > maxLength ? ( - - {word} - - ) : ( - word - ); - }); -}; - export const ListingCardComponent = props => { const { className, rootClassName, intl, listing, setActiveListing } = props; const classes = classNames(rootClassName || css.root, className); @@ -92,7 +76,12 @@ export const ListingCardComponent = props => {
-
{formatTitle(title, MAX_LENGTH_FOR_WORDS_IN_TITLE)}
+
+ {richText(title, { + longWordMinLength: MIN_LENGTH_FOR_LONG_WORDS, + longWordClass: css.longWord, + })} +
{ const { className, listing, children } = props; @@ -21,6 +26,15 @@ const ListingLink = props => { const id = listing.id.uuid; const { title, state } = listing.attributes; const slug = createSlug(title); + const richTitle = ( + + {richText(title, { + longWordMinLength: MIN_LENGTH_FOR_LONG_WORDS, + longWordClass: css.longWord, + })} + + ); + const isPendingApproval = state === LISTING_STATE_PENDING_APPROVAL; const linkProps = isPendingApproval ? { @@ -37,7 +51,7 @@ const ListingLink = props => { }; return ( - {children ? children : listing.attributes.title || ''} + {children ? children : richTitle || ''} ); }; diff --git a/src/containers/ListingPage/ListingPage.css b/src/containers/ListingPage/ListingPage.css index 71634bb3..9e043d21 100644 --- a/src/containers/ListingPage/ListingPage.css +++ b/src/containers/ListingPage/ListingPage.css @@ -744,3 +744,11 @@ padding: 15px 24px 15px 24px; text-align: center; } + +.longWord { + /* fallback option */ + word-break: break-all; + /* use break-word if available */ + word-break: break-word; + hyphens: auto; +} diff --git a/src/containers/ListingPage/ListingPage.js b/src/containers/ListingPage/ListingPage.js index fc0108a7..6caff5e2 100644 --- a/src/containers/ListingPage/ListingPage.js +++ b/src/containers/ListingPage/ListingPage.js @@ -14,6 +14,7 @@ import { LISTING_PAGE_PENDING_APPROVAL_VARIANT, createSlug, parse } from '../../ import { formatMoney } from '../../util/currency'; import { createResourceLocatorString, findRouteByRouteName } from '../../util/routes'; import { ensureListing, ensureOwnListing, ensureUser, userDisplayName } from '../../util/data'; +import { richText } from '../../util/richText'; import { getMarketplaceEntities } from '../../ducks/marketplaceData.duck'; import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck'; import { @@ -47,6 +48,8 @@ import css from './ListingPage.css'; // This defines when ModalInMobile shows content as Modal const MODAL_BREAKPOINT = 1023; +const MIN_LENGTH_FOR_LONG_WORDS_IN_TITLE = 16; +const MIN_LENGTH_FOR_LONG_WORDS_IN_DESCRIPTION = 20; const { UUID } = sdkTypes; @@ -292,6 +295,15 @@ export class ListingPageComponent extends Component { publicData, } = currentListing.attributes; + const richTitle = ( + + {richText(title, { + longWordMinLength: MIN_LENGTH_FOR_LONG_WORDS_IN_TITLE, + longWordClass: css.longWord, + })} + + ); + const category = publicData && publicData.category ? ( @@ -393,7 +405,7 @@ export class ListingPageComponent extends Component { const bookingHeading = (

- +

-

{title}

+

{richTitle}

{category} @@ -587,7 +599,12 @@ export class ListingPageComponent extends Component {

-

{description}

+

+ {richText(description, { + longWordMinLength: MIN_LENGTH_FOR_LONG_WORDS_IN_DESCRIPTION, + longWordClass: css.longWord, + })} +

@@ -663,7 +680,7 @@ export class ListingPageComponent extends Component { onManageDisableScrolling={onManageDisableScrolling} >
-

{title}

+

{richTitle}

- listing1 title + + listing1 + + title +

- listing1 description + listing1 + + description

@@ -431,7 +437,11 @@ exports[`ListingPage matches snapshot 1`] = ` >

- listing1 title + + listing1 + + title +

@@ -452,7 +462,11 @@ exports[`ListingPage matches snapshot 1`] = ` id="ListingPage.bookingTitle" values={ Object { - "title": "listing1 title", + "title": + listing1 + + title + , } } /> diff --git a/src/util/richText.js b/src/util/richText.js new file mode 100644 index 00000000..c918d2c7 --- /dev/null +++ b/src/util/richText.js @@ -0,0 +1,135 @@ +import React from 'react'; +import { chain } from 'lodash'; +import { ExternalLink } from '../components'; + +/** + * Add zero width space (zwsp) around given breakchars (default '/') to make word break possible. + * E.g. "one/two/three" => ["one", "​/​", "two" "​/​" "three"] + * + * @param {string} wordToBreak word to be broken from special character points. + * @param {string} breakChars string containing possible chars that can be surrounded with zwsp. + * @return {Array} returns an array containing strings- + */ +export const zwspAroundSpecialCharsSplit = (wordToBreak, breakChars = '/') => { + if (typeof wordToBreak !== 'string') { + return wordToBreak; + } + + const bcArray = breakChars.split(''); + + // Escape special regular expression chars + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions + const escapedBCArray = bcArray.map(c => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + const reSplit = new RegExp('([' + escapedBCArray.join('') + '])'); + + const zwsp = '​'; + return wordToBreak.split(reSplit).map(w => (bcArray.includes(w) ? `${zwsp}${w}${zwsp}` : w)); +}; + +/** + * Layouts are not fixed sizes - So, long words in text make flexboxed items to grow too big. + * This wraps long words with span and adds given class to it + * + * @param {string} word to be wrapped if requirement (longWordMinLength) is met + * @param {number} key span needs a key in React/JSX + * @param {number} longWordMinLength minimum length when word is considered long + * @param {string} longWordClass class to be added to spans + * @return {node} returns a string or component + */ +export const wrapLongWord = (word, key, options = {}) => { + const { longWordMinLength, longWordClass } = options; + if (typeof word !== 'string' || !(longWordMinLength && longWordClass)) { + return word; + } + + const isShortWord = word.length <= longWordMinLength; + return isShortWord ? ( + word + ) : ( + + {word} + + ); +}; + +/** + * Find links from words and surround them with component + * + * @param {string} word to be linkified if requirement (link) is met + * @param {number} key span needs a key in React/JSX + * @param {Object} options than can contain keys: linkify, linkClass. + * @return {Array} returns a array containing ExternalLink component or strings + */ +export const linkifyOrWrapLinkSplit = (word, key, options = {}) => { + if (typeof word !== 'string') { + return word; + } + const { linkify, linkClass } = options; + + // TODO This can't handle links that contain parenthesis: + // '(http://example.org/path_(etc))' + // Currently extracts: + // '(http://example.org/path_(etc))' + // + // We need to + // 1) track whether token before link contains parenthesis as a last character + // before link token ("word.split(urlRegex)[linkIndex - 1]") and + // 2) add enough characters to the end of link-token from the next token + // after link ("word.split(urlRegex)[linkIndex + 1]") + + // urlRegex modified from examples in + // https://stackoverflow.com/questions/1500260/detect-urls-in-text-with-javascript + + // eslint-disable-next-line no-useless-escape + const urlRegex = /(\bhttps?:\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gi; + if (word.match(urlRegex)) { + // Split strings like "(http://www.example.com)" to ["(","http://www.example.com",")"] + return word.split(urlRegex).map(w => { + return !w.match(urlRegex) ? ( + w + ) : linkify ? ( + + {w} + + ) : linkClass ? ( + + {w} + + ) : ( + w + ); + }); + } else { + return word; + } +}; + +/** + * Scan text to fill in wrappers for long words and add links. + * Wrap long words: options should contain longWordMinLength & longWordClass + * Linkify found links: options should contain "linkify: true" (linkClass is optional) + * + * @param {string} text check text content + * @param {object} options { longWordMinLength, longWordClass, linkify = false, linkClass } + * @return {Array} returns a child array containing strings and inline elements + */ +export const richText = (text, options) => { + if (typeof text !== 'string') { + return text; + } + + // longWordMinLength & longWordClass are needed for long words to be spanned + // linkify = true is needed for links to be linkified (linkClass is optional) + const { longWordMinLength, longWordClass, linkify = false, linkClass } = options; + const linkOrLongWordClass = linkClass ? linkClass : longWordClass; + const nonWhiteSpaceSequence = /([^\s]+)/gi; + + return text.split(nonWhiteSpaceSequence).reduce((acc, nextChild, i) => { + const parts = chain([nextChild]) + .flatMap(w => linkifyOrWrapLinkSplit(w, i, { linkify, linkClass: linkOrLongWordClass })) + .flatMap(w => zwspAroundSpecialCharsSplit(w, '/,')) + .map((w, j) => wrapLongWord(w, `${i}${j}`, { longWordMinLength, longWordClass })) + .value(); + return acc.concat(parts); + }, []); +}; diff --git a/src/util/richText.test.js b/src/util/richText.test.js new file mode 100644 index 00000000..c0c327d0 --- /dev/null +++ b/src/util/richText.test.js @@ -0,0 +1,288 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { + zwspAroundSpecialCharsSplit, + linkifyOrWrapLinkSplit, + wrapLongWord, + richText, +} from './richText'; + +describe('richText', () => { + // There variables contain zero-width-space on both sides of the visible character. + const slashWithZWSP = '​/​'; + const commaWithZWSP = '​,​'; + const dollarSignWithZWSP = '​$​'; + const backslashWithZWSP = '​\\​'; + + describe('zwspAroundSpecialChars(word, optionalBreakingChars)', () => { + it('should not add anything to strings withouth word boundary', () => { + expect(zwspAroundSpecialCharsSplit('word')).toEqual(['word']); + }); + it('should add zwsp to a word with slash', () => { + expect(zwspAroundSpecialCharsSplit('word/another')).toEqual([ + 'word', + slashWithZWSP, + 'another', + ]); + }); + it('should add zwsp to strings with spaces and slashes', () => { + expect(zwspAroundSpecialCharsSplit('word one/another/third word')).toEqual([ + 'word one', + slashWithZWSP, + 'another', + slashWithZWSP, + 'third word', + ]); + }); + it('should add zwsp to strings with spaces and slashes when "/" is given', () => { + expect(zwspAroundSpecialCharsSplit('word one/another/third word', '/')).toEqual([ + 'word one', + slashWithZWSP, + 'another', + slashWithZWSP, + 'third word', + ]); + }); + + it('should add zwsp to strings with given char (","), no char', () => { + expect(zwspAroundSpecialCharsSplit('word/another', ',')).toEqual(['word/another']); + }); + it('should add zwsp to strings with given char (","), single char', () => { + expect(zwspAroundSpecialCharsSplit('word,another', ',')).toEqual([ + 'word', + commaWithZWSP, + 'another', + ]); + }); + it('should add zwsp to strings with given chars (","), single char and spaces', () => { + expect(zwspAroundSpecialCharsSplit('word,another/third forth', ',')).toEqual([ + 'word', + commaWithZWSP, + 'another/third forth', + ]); + }); + + it('should add zwsp to strings with given chars (",/"): multiple occurrences', () => { + expect( + zwspAroundSpecialCharsSplit('word/another/third,fourth and fifth,sixth', ',/') + ).toEqual([ + 'word', + slashWithZWSP, + 'another', + slashWithZWSP, + 'third', + commaWithZWSP, + 'fourth and fifth', + commaWithZWSP, + 'sixth', + ]); + }); + it('should add zwsp to strings with given chars (",/:"), when not-found-chars are included', () => { + expect(zwspAroundSpecialCharsSplit('word/another,third', '.,/:')).toEqual([ + 'word', + slashWithZWSP, + 'another', + commaWithZWSP, + 'third', + ]); + }); + + it('should add zwsp to strings with given chars ("?\\$") escapable chars not found', () => { + expect(zwspAroundSpecialCharsSplit('word/another,third', '?\\$')).toEqual([ + 'word/another,third', + ]); + }); + it('should add zwsp to strings with given chars ("?\\$") escapable chars $ found', () => { + expect(zwspAroundSpecialCharsSplit('word/another$third', '?\\$')).toEqual([ + 'word/another', + dollarSignWithZWSP, + 'third', + ]); + }); + it('should add zwsp to strings with given chars ("?\\$") escapable chars backslash found', () => { + expect(zwspAroundSpecialCharsSplit('word/another\\third', '?\\$')).toEqual([ + 'word/another', + backslashWithZWSP, + 'third', + ]); + }); + }); + + describe('wrapLongWord(word, key, longWordMinLength, longWordClass)', () => { + it('should not add anything to short word', () => { + const wrapper = shallow( + + {wrapLongWord('word', 'key', { longWordMinLength: 10, longWordClass: 'longWord' })} + + ); + expect(wrapper.html()).toEqual('word'); + }); + it('should add span around long word', () => { + const wrapper = shallow( + + {wrapLongWord('Pneumonoultramicroscopicsilicovolcanoconiosis', 'key', { + longWordMinLength: 10, + longWordClass: 'longWord', + })} + + ); + expect(wrapper.html()).toEqual( + 'Pneumonoultramicroscopicsilicovolcanoconiosis' + ); + }); + }); + + describe('linkifyOrWrapLinkSplit(word, key, linkClass)', () => { + it('should not add anything to words without links', () => { + const wrapper = shallow( + + {linkifyOrWrapLinkSplit('word', 'key', { linkify: true, linkClass: 'linkClass' })} + + ); + expect(wrapper.html()).toEqual('word'); + }); + it('should add link around words that are links', () => { + const wrapper = shallow( + + {linkifyOrWrapLinkSplit('http://www.example.com', 'key', { + linkify: true, + linkClass: 'linkClass', + })} + + ); + expect(wrapper.html()).toEqual( + 'http://www.example.com' + ); + }); + it('should add link around words that are links even inside parenthesis', () => { + const wrapper = shallow( + + {linkifyOrWrapLinkSplit('(http://www.example.com)', 'key', { + linkify: true, + linkClass: 'linkClass', + })} + + ); + expect(wrapper.html()).toEqual( + '(http://www.example.com)' + ); + }); + it('should add link around words that are links even inside brackets', () => { + const wrapper = shallow( + + {linkifyOrWrapLinkSplit('[http://www.example.com]', 'key', { + linkify: true, + linkClass: 'linkClass', + })} + + ); + expect(wrapper.html()).toEqual( + '[http://www.example.com]' + ); + }); + }); + + describe('richText(text, { longWordMinLength, longWordClass })', () => { + const options = { longWordMinLength: 10, longWordClass: 'longWord' }; + + it('should not add anything to strings with short words', () => { + const wrapper = shallow({richText('word word word', options)}); + + expect(wrapper.html()).toEqual('word word word'); + }); + it('should add span around a string with a single long word', () => { + const wrapper = shallow( + + {richText( + 'word Pneumonoultramicroscopicsilicovolcanoconiosis is the longest word', + options + )} + + ); + expect(wrapper.html()).toEqual( + 'word Pneumonoultramicroscopicsilicovolcanoconiosis is the longest word' + ); + }); + it('should add span around a string with multiple long words', () => { + const wrapper = shallow( + + {richText( + 'word Pneumonoultramicroscopicsilicovolcanoconiosis is the longest word - Pseudopseudohypoparathyroidism is shorter', + options + )} + + ); + expect(wrapper.html()).toEqual( + 'word Pneumonoultramicroscopicsilicovolcanoconiosis is the longest word - Pseudopseudohypoparathyroidism is shorter' + ); + }); + + it('should add span around a string with multiple long words and containing slashes', () => { + const wrapper = shallow( + {richText('Chars one/two/three - count until exhaustion…', options)} + ); + // + // Chars one​/​two​/​three - count until exhaustion… + // + expect(wrapper.html()).toEqual( + `Chars one${slashWithZWSP}two${slashWithZWSP}three - count until exhaustion…` + ); + }); + + it('should add span around a string with a long word and containing slashes and commas', () => { + const wrapper = shallow( + {richText('Chars one/two/three, count until exhaustion…', options)} + ); + // + // Chars one​/​two​/​three​,​ count until exhaustion… + // + expect(wrapper.html()).toEqual( + `Chars one${slashWithZWSP}two${slashWithZWSP}three${commaWithZWSP} count until exhaustion…` + ); + }); + + it('should add span around a string with a long word, containing slashes and a link', () => { + const wrapper = shallow( + + {richText( + 'Chars one/two/three - count until exhaustion… and a random link: http://www.example.com', + { ...options, linkify: true, linkClass: 'link' } + )} + + ); + // + // Chars one​/​two​/​three - count until exhaustion… and a random link: http://www.example.com + // + expect(wrapper.html()).toEqual( + `Chars one${slashWithZWSP}two${slashWithZWSP}three - count until exhaustion… and a random link: http://www.example.com` + ); + }); + it('should add link inside non-whitespace-sequence (http://example.com)', () => { + const wrapper = shallow( + {richText('Link: (http://example.com)', { ...options, linkify: true })} + ); + // + // Link: (http://example.com) + // + expect(wrapper.html()).toEqual( + `Link: (http://example.com)` + ); + }); + it('should not add span around a string if no linkify option is given', () => { + const wrapper = shallow( + + {richText( + 'Chars one/two/three - count until exhaustion… and a random link: http://www.example.com', + options + )} + + ); + // + // Chars one​/​two​/​three - count until exhaustion… and a random link: http:​/​​/​www.example.com + // + expect(wrapper.html()).toEqual( + `Chars one${slashWithZWSP}two${slashWithZWSP}three - count until exhaustion… and a random link: http://www.example.com` + ); + }); + }); +});