diff --git a/.storybook/polaris-readme-loader.js b/.storybook/polaris-readme-loader.js index 5d515972b51..aece5a63500 100644 --- a/.storybook/polaris-readme-loader.js +++ b/.storybook/polaris-readme-loader.js @@ -148,6 +148,7 @@ import { Link, List, Loading, + MediaCard, Modal, Navigation, OptionList, @@ -189,7 +190,8 @@ import { TrapFocus, Truncate, UnstyledLink, - VisuallyHidden + VisuallyHidden, + VideoThumbnail } from '@shopify/polaris'; import { PlusMinor, diff --git a/UNRELEASED.md b/UNRELEASED.md index f50d13b618d..76b2fabd75d 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,8 +2,15 @@ ### Breaking changes +### New components + +- Added [`MediaCard`](https://polaris.shopify.com/components/structure/video-card) and [`VideoThumbnail`](https://polaris.shopify.com/components/images-and-icons/video-thumbnail) ([#2725](https://github.com/Shopify/polaris-react/pull/2725)) +- Added [`VideoThumbnail`](https://polaris.shopify.com/components/images-and-icons/video-thumbnail) ([#2725](https://github.com/Shopify/polaris-react/pull/2725)) + ### Enhancements +- Added utilities for parsing video duration (https://polaris.shopify.com/components/images-and-icons/video-thumbnail) ([#2725](https://github.com/Shopify/polaris-react/pull/2725)) + ### Bug fixes ### Documentation diff --git a/locales/en.json b/locales/en.json index 2d3a18c0b80..9ceea37d7f2 100644 --- a/locales/en.json +++ b/locales/en.json @@ -4,11 +4,9 @@ "label": "Avatar", "labelWithInitials": "Avatar with initials {initials}" }, - "Autocomplete": { "spinnerAccessibilityLabel": "Loading" }, - "Badge": { "PROGRESS_LABELS": { "incomplete": "Incomplete", @@ -23,12 +21,10 @@ "new": "New" } }, - "Button": { "spinnerAccessibilityLabel": "Loading", "connectedDisclosureAccessibilityLabel": "Related actions" }, - "Common": { "checkbox": "checkbox", "undo": "Undo", @@ -39,19 +35,16 @@ "submit": "Submit", "more": "More" }, - "ContextualSaveBar": { "save": "Save", "discard": "Discard" }, - "DataTable": { "sortAccessibilityLabel": "sort {direction} by", "navAccessibilityLabel": "Scroll table {direction} one column", "totalsRowHeading": "Totals", "totalRowHeading": "Total" }, - "DatePicker": { "previousMonth": "Show previous month, {previousMonthName} {showPreviousYear}", "nextMonth": "Show next month, {nextMonth} {nextYear}", @@ -80,20 +73,17 @@ "sunday": "Su" } }, - "DiscardConfirmationModal": { "title": "Discard all unsaved changes", "message": "If you discard changes, you’ll delete any edits you made since you last saved.", "primaryAction": "Discard changes", "secondaryAction": "Continue editing" }, - "DropZone": { "overlayTextFile": "Drop file to upload", "overlayTextImage": "Drop image to upload", "errorOverlayTextFile": "File type is not valid", "errorOverlayTextImage": "Image type is not valid", - "FileUpload": { "actionTitleFile": "Add file", "actionTitleImage": "Add image", @@ -102,28 +92,23 @@ "label": "Upload file" } }, - "EmptySearchResult": { "altText": "Empty search results" }, - "Frame": { "skipToContent": "Skip to content", "Navigation": { "closeMobileNavigationLabel": "Close navigation" } }, - "Icon": { "backdropWarning": "The {color} icon doesn’t accept backdrops. The icon colors that have backdrops are: {colorsWithBackDrops}" }, - "ActionMenu": { "RollupActions": { "rollupButton": "Actions" } }, - "Filters": { "moreFilters": "More filters", "moreFiltersWithCount": "More filters ({count})", @@ -135,23 +120,19 @@ "clear": "Clear", "clearLabel": "Clear {filterName}" }, - "Modal": { "iFrameTitle": "body markup", "modalWarning": "These required properties are missing from Modal: {missingProps}" }, - "Pagination": { "previous": "Previous", "next": "Next", "pagination": "Pagination" }, - "ProgressBar": { "negativeWarningMessage": "Values passed to the progress prop shouldn’t be negative. Resetting {progress} to 0.", "exceedWarningMessage": "Values passed to the progress prop shouldn’t exceed 100. Setting {progress} to 100." }, - "ResourceList": { "sortingLabel": "Sort by", "defaultItemSingular": "item", @@ -171,34 +152,28 @@ "a11yCheckboxSelectAllMultiple": "Select all {itemsLength} {resourceNamePlural}", "ariaLiveSingular": "{itemsLength} item", "ariaLivePlural": "{itemsLength} items", - "Item": { "actionsDropdownLabel": "Actions for {accessibilityLabel}", "actionsDropdown": "Actions dropdown", "viewItem": "View details for {itemName}" }, - "BulkActions": { "actionsActivatorLabel": "Actions", "moreActionsActivatorLabel": "More actions", "warningMessage": "To provide a better user experience. There should only be a maximum of {maxPromotedActions} promoted actions." }, - "FilterCreator": { "filterButtonLabel": "Filter", "selectFilterKeyPlaceholder": "Select a filter\u2026", "addFilterButtonLabel": "Add filter", "showAllWhere": "Show all {resourceNamePlural} where:" }, - "FilterControl": { "textFieldLabel": "Search {resourceNamePlural}" }, - "FilterValueSelector": { "selectFilterValuePlaceholder": "Select a filter\u2026" }, - "DateSelector": { "dateFilterLabel": "Select a value", "dateValueLabel": "Date", @@ -230,35 +205,79 @@ } } }, - "SkeletonPage": { "loadingLabel": "Page loading" }, - "Spinner": { "warningMessage": "The color {color} is not meant to be used on {size} spinners. The colors available on large spinners are: {colors}" }, - "Tabs": { "toggleTabsLabel": "More tabs" }, - "Tag": { "ariaLabel": "Remove {children}" }, - "TextField": { "characterCount": "{count} characters", "characterCountWithMaxLength": "{count} of {limit} characters used" }, - "TopBar": { "toggleMenuLabel": "Toggle menu", - "SearchField": { "clearButtonLabel": "Clear", "search": "Search" } + }, + "MediaCard": { + "popoverButton": "Actions" + }, + "VideoThumbnail": { + "playButtonA11yLabel": { + "default": "Play video", + "defaultWithDuration": "Play video of length {duration}", + "duration": { + "hours": { + "other": { + "only": "{hourCount} hours", + "andMinutes": "{hourCount} hours and {minuteCount} minutes", + "andMinute": "{hourCount} hours and {minuteCount} minute", + "minutesAndSeconds": "{hourCount} hours, {minuteCount} minutes, and {secondCount} seconds", + "minutesAndSecond": "{hourCount} hours, {minuteCount} minutes, and {secondCount} second", + "minuteAndSeconds": "{hourCount} hours, {minuteCount} minute, and {secondCount} seconds", + "minuteAndSecond": "{hourCount} hours, {minuteCount} minute, and {secondCount} second", + "andSeconds": "{hourCount} hours and {secondCount} seconds", + "andSecond": "{hourCount} hours and {secondCount} second" + }, + "one": { + "only": "{hourCount} hour", + "andMinutes": "{hourCount} hour and {minuteCount} minutes", + "andMinute": "{hourCount} hour and {minuteCount} minute", + "minutesAndSeconds": "{hourCount} hour, {minuteCount} minutes, and {secondCount} seconds", + "minutesAndSecond": "{hourCount} hour, {minuteCount} minutes, and {secondCount} second", + "minuteAndSeconds": "{hourCount} hour, {minuteCount} minute, and {secondCount} seconds", + "minuteAndSecond": "{hourCount} hour, {minuteCount} minute, and {secondCount} second", + "andSeconds": "{hourCount} hour and {secondCount} seconds", + "andSecond": "{hourCount} hour and {secondCount} second" + } + }, + "minutes": { + "other": { + "only": "{minuteCount} minutes", + "andSeconds": "{minuteCount} minutes and {secondCount} seconds", + "andSecond": "{minuteCount} minutes and {secondCount} second" + }, + "one": { + "only": "{minuteCount} minute", + "andSeconds": "{minuteCount} minute and {secondCount} seconds", + "andSecond": "{minuteCount} minute and {secondCount} second" + } + }, + "seconds": { + "other": "{secondCount} seconds", + "one": "{secondCount} second" + } + } + } } } } diff --git a/src/components/MediaCard/MediaCard.scss b/src/components/MediaCard/MediaCard.scss new file mode 100644 index 00000000000..d05cd56164e --- /dev/null +++ b/src/components/MediaCard/MediaCard.scss @@ -0,0 +1,63 @@ +@import '../../styles/common'; + +$portrait-breakpoint: 804px; + +.MediaCard { + height: 100%; + width: 100%; + display: flex; + flex-flow: row wrap; + + &.portrait { + flex-flow: column nowrap; + } + + @include breakpoint-before($portrait-breakpoint, inclusive) { + flex-flow: column nowrap; + } +} + +.MediaContainer { + &:not(.portrait) { + flex-basis: 40%; + } +} + +.InfoContainer { + position: relative; + + &:not(.portrait) { + flex-basis: 60%; + } +} + +.Popover { + position: absolute; + z-index: z-index(overlay); + top: spacing(); + right: spacing(); +} + +.Heading { + margin-right: spacing(extra-loose); +} + +.PrimaryAction { + margin-right: spacing(tight); +} + +.SecondaryAction { + margin-left: -spacing(tight); +} + +.ActionContainer { + padding-top: spacing(tight); + + &.portrait { + padding-top: spacing(extra-loose); + } + + @include breakpoint-before($portrait-breakpoint, inclusive) { + padding-top: spacing(extra-loose); + } +} diff --git a/src/components/MediaCard/MediaCard.tsx b/src/components/MediaCard/MediaCard.tsx new file mode 100644 index 00000000000..e60ec8ad674 --- /dev/null +++ b/src/components/MediaCard/MediaCard.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import {HorizontalDotsMinor} from '@shopify/polaris-icons'; + +import {useToggle} from '../../utilities/use-toggle'; +import {classNames} from '../../utilities/css'; +import {useI18n} from '../../utilities/i18n'; +import {Action, ActionListItemDescriptor} from '../../types'; +import {Card} from '../Card'; +import {Button, buttonFrom} from '../Button'; +import {Heading} from '../Heading'; +import {Popover} from '../Popover'; +import {ActionList} from '../ActionList'; +import {ButtonGroup} from '../ButtonGroup'; +import {Stack} from '../Stack'; + +import styles from './MediaCard.scss'; + +interface MediaCardProps { + /** The visual media to display in the card */ + children: React.ReactNode; + /** Heading content */ + title: string; + /** Body content */ + description: string; + /** Main call to action, rendered as a basic button */ + primaryAction: Action; + /** Secondary call to action, rendered as a plain button */ + secondaryAction?: Action; + /** Action list items to render in ellipsis popover */ + popoverActions?: ActionListItemDescriptor[]; + /** Whether or not card content should be laid out vertically + * @default false + */ + portrait?: boolean; +} + +export function MediaCard({ + title, + children, + primaryAction, + secondaryAction, + description, + popoverActions = [], + portrait = false, +}: MediaCardProps) { + const i18n = useI18n(); + const {value: popoverActive, toggle: togglePopoverActive} = useToggle(false); + + const popoverActivator = ( + + {timeStampMarkup} + + ); +} diff --git a/src/components/VideoThumbnail/illustrations/index.ts b/src/components/VideoThumbnail/illustrations/index.ts new file mode 100644 index 00000000000..f65021f49a0 --- /dev/null +++ b/src/components/VideoThumbnail/illustrations/index.ts @@ -0,0 +1 @@ +export {default as PlayIcon} from './play.svg'; diff --git a/src/components/VideoThumbnail/illustrations/play.svg b/src/components/VideoThumbnail/illustrations/play.svg new file mode 100644 index 00000000000..11df3551df2 --- /dev/null +++ b/src/components/VideoThumbnail/illustrations/play.svg @@ -0,0 +1 @@ + diff --git a/src/components/VideoThumbnail/index.ts b/src/components/VideoThumbnail/index.ts new file mode 100644 index 00000000000..3384afea11b --- /dev/null +++ b/src/components/VideoThumbnail/index.ts @@ -0,0 +1 @@ +export * from './VideoThumbnail'; diff --git a/src/components/VideoThumbnail/tests/VideoThumbnail.test.tsx b/src/components/VideoThumbnail/tests/VideoThumbnail.test.tsx new file mode 100644 index 00000000000..b4344361bcd --- /dev/null +++ b/src/components/VideoThumbnail/tests/VideoThumbnail.test.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import {mountWithApp} from 'test-utilities'; + +import {VideoThumbnail} from '../VideoThumbnail'; + +describe('', () => { + const spyClick = jest.fn(); + const mockProps = { + thumbnailUrl: '', + onClick: spyClick, + }; + + describe('thumbnailUrl', () => { + it('renders with play button and custom overlay', () => { + const videoThumbnail = mountWithApp(); + + expect(videoThumbnail.find('button')).not.toBeNull(); + expect( + videoThumbnail.find('div', {className: 'Thumbnail'})!.prop('style')! + .backgroundImage, + ).toBe(`url(${mockProps.thumbnailUrl})`); + }); + }); + + describe('videoLength', () => { + it('does not render a timestamp if not provided', () => { + const videoThumbnail = mountWithApp( + , + ); + expect(videoThumbnail).not.toContainReactComponent('p', { + className: 'Timestamp', + }); + }); + + it('renders a timestamp with seconds only when less than 60 seconds', () => { + const videoThumbnail = mountWithApp( + , + ); + + const timestamp = videoThumbnail + .find('p', { + className: 'Timestamp', + }) + ?.text(); + + expect(timestamp).toStrictEqual('0:45'); + }); + + it('renders a timestamp with seconds and minutes only when less than 60 minutes', () => { + const videoThumbnail = mountWithApp( + , + ); + + const timestamp = videoThumbnail + .find('p', { + className: 'Timestamp', + }) + ?.text(); + + expect(timestamp).toStrictEqual('2:15'); + }); + + it('renders timestamp with seconds, minutes, and hours when greater than 60 minutes', () => { + const videoThumbnail = mountWithApp( + , + ); + + const timestamp = videoThumbnail + .find('p', { + className: 'Timestamp', + }) + ?.text(); + + expect(timestamp).toStrictEqual('1:02:25'); + }); + }); + + describe('aria-label', () => { + it('sets the accessibilityLabel on the aria-label attribute when provided', () => { + const accessibilityLabel = 'test'; + const videoThumbnail = mountWithApp( + , + ); + expect(videoThumbnail.find('button')!.prop('aria-label')).toStrictEqual( + accessibilityLabel, + ); + }); + + describe('when videoLength is provided', () => { + const defaultLabelWithDuration = 'Play video of length'; + + it('sets the default label with time in seconds when less than 60 seconds', () => { + const videoLength = 45; + const videoThumbnail = mountWithApp( + , + ); + + const actualLabel = videoThumbnail.find('button')!.prop('aria-label'); + const expectedLabel = `${defaultLabelWithDuration} 45 seconds`; + + expect(actualLabel).toStrictEqual(expectedLabel); + }); + + it('sets the default label with time in seconds and minutes when less than 60 minutes', () => { + const videoLength = 135; + const videoThumbnail = mountWithApp( + , + ); + + const actualLabel = videoThumbnail.find('button')!.prop('aria-label'); + const expectedLabel = `${defaultLabelWithDuration} 2 minutes and 15 seconds`; + + expect(actualLabel).toStrictEqual(expectedLabel); + }); + + it('sets the default label with time in seconds, minutes, and hours when greater than 60 minutes', () => { + const videoLength = 3745; + const videoThumbnail = mountWithApp( + , + ); + + const actualLabel = videoThumbnail.find('button')!.prop('aria-label'); + const expectedLabel = `${defaultLabelWithDuration} 1 hour, 2 minutes, and 25 seconds`; + + expect(actualLabel).toStrictEqual(expectedLabel); + }); + }); + }); + + describe('onClick', () => { + it('calls the onClick when the play button is clicked', () => { + const videoThumbnail = mountWithApp(); + videoThumbnail.find('button')!.trigger('onClick'); + expect(spyClick).toHaveBeenCalled(); + }); + }); + + describe('onBeforeStartPlaying', () => { + it('calls the onMouseEnter when the enter button is pressed', () => { + const spyOnBeforeStart = jest.fn(); + const videoThumbnail = mountWithApp( + , + ); + videoThumbnail.find('button')!.trigger('onMouseEnter'); + expect(spyOnBeforeStart).toHaveBeenCalled(); + }); + + it('calls the onTouchStart when the play button is pressed', () => { + const spyOnBeforeStart = jest.fn(); + const videoThumbnail = mountWithApp( + , + ); + videoThumbnail.find('button')!.trigger('onTouchStart'); + expect(spyOnBeforeStart).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/index.ts b/src/components/index.ts index 85d4bbda179..8593d87869e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -158,6 +158,8 @@ export type {ListProps} from './List'; export {Loading} from './Loading'; export type {LoadingProps} from './Loading'; +export {MediaCard} from './MediaCard'; + export {Modal} from './Modal'; export type {ModalProps} from './Modal'; @@ -295,5 +297,7 @@ export type {TruncateProps} from './Truncate'; export {UnstyledLink} from './UnstyledLink'; export type {UnstyledLinkProps} from './UnstyledLink'; +export {VideoThumbnail} from './VideoThumbnail'; + export {VisuallyHidden} from './VisuallyHidden'; export type {VisuallyHiddenProps} from './VisuallyHidden'; diff --git a/src/utilities/duration.ts b/src/utilities/duration.ts new file mode 100644 index 00000000000..15cacef3de6 --- /dev/null +++ b/src/utilities/duration.ts @@ -0,0 +1,65 @@ +const MINUTE = 60; +const HOUR = MINUTE * 60; + +export function ensureTwoDigits(num: number): string { + return num > 9 ? String(num) : `0${num}`; +} + +export function secondsToTimeComponents( + seconds: number, +): {hours: number; minutes: number; seconds: number} { + return { + hours: Math.floor(seconds / HOUR), + minutes: Math.floor((seconds % HOUR) / MINUTE), + seconds: seconds % MINUTE, + }; +} + +export function secondsToTimestamp(numSeconds: number) { + const {hours, minutes, seconds} = secondsToTimeComponents(numSeconds); + const hasHours = numSeconds > HOUR; + const hoursText = hasHours ? `${hours}:` : ''; + const minutesText = `${hasHours ? ensureTwoDigits(minutes) : minutes}:`; + const secondsText = `${ensureTwoDigits(seconds)}`; + + return `${hoursText}${minutesText}${secondsText}`; +} + +export function secondsToDurationTranslationKey(numSeconds: number) { + const {hours, minutes, seconds} = secondsToTimeComponents(numSeconds); + let durationKey = 'Polaris.VideoThumbnail.playButtonA11yLabel.duration'; + + if (hours) { + durationKey += `.hours.${hours > 1 ? 'other' : 'one'}`; + + if (seconds) { + if (minutes > 1) { + durationKey += `${ + seconds > 1 ? '.minutesAndSeconds' : '.minutesAndSecond' + }`; + } else if (minutes === 1) { + durationKey += `${ + seconds > 1 ? '.minuteAndSeconds' : '.minuteAndSecond' + }`; + } else { + durationKey += `${seconds > 1 ? '.andSeconds' : '.andSecond'}`; + } + } else if (minutes) { + durationKey += `${minutes > 1 ? '.andMinutes' : '.andMinute'}`; + } else { + durationKey += '.only'; + } + } else if (minutes) { + durationKey += `.minutes.${minutes > 1 ? 'other' : 'one'}`; + + if (seconds) { + durationKey += `${seconds > 1 ? '.andSeconds' : '.andSecond'}`; + } else { + durationKey += '.only'; + } + } else if (seconds) { + durationKey += seconds > 1 ? '.seconds.other' : '.seconds.one'; + } + + return durationKey; +} diff --git a/src/utilities/tests/duration.test.ts b/src/utilities/tests/duration.test.ts new file mode 100644 index 00000000000..b9b5095bfd6 --- /dev/null +++ b/src/utilities/tests/duration.test.ts @@ -0,0 +1,197 @@ +import { + ensureTwoDigits, + secondsToTimeComponents, + secondsToTimestamp, + secondsToDurationTranslationKey, +} from '../duration'; + +describe(' utilities', () => { + describe('ensureTwoDigits', () => { + it('stringifies the number when greater than 9', () => { + expect(ensureTwoDigits(12)).toStrictEqual('12'); + }); + + it('stringifies the number with a leading zero when less than 9', () => { + expect(ensureTwoDigits(8)).toStrictEqual('08'); + }); + }); + + describe('secondsToTimeComponents', () => { + it('sets hours and minutes to zero when numSeconds is < 60 seconds', () => { + const actualTimeComponents = secondsToTimeComponents(45); + const expectedTimeComponents = {hours: 0, minutes: 0, seconds: 45}; + + expect(actualTimeComponents).toStrictEqual(expectedTimeComponents); + }); + + it('sets hours to zero when numSeconds is > 60 seconds and < 60 minutes', () => { + const actualTimeComponents = secondsToTimeComponents(145); + const expectedTimeComponents = {hours: 0, minutes: 2, seconds: 25}; + + expect(actualTimeComponents).toStrictEqual(expectedTimeComponents); + }); + + it('sets hours and minutes values when numSeconds is > 60 minutes', () => { + const actualTimeComponents = secondsToTimeComponents(3745); + const expectedTimeComponents = {hours: 1, minutes: 2, seconds: 25}; + + expect(actualTimeComponents).toStrictEqual(expectedTimeComponents); + }); + }); + + describe('secondsToTimestamp', () => { + describe('when numSeconds is > 60 minutes', () => { + it('includes hours in the timestamp', () => { + expect(secondsToTimestamp(4745)).toStrictEqual('1:19:05'); + }); + + it('adds a leading zero to minutes when less than 10', () => { + expect(secondsToTimestamp(3745)).toStrictEqual('1:02:25'); + }); + }); + + describe('when numSeconds is > 60 seconds and < 60 minutes', () => { + it('does not include hours in the timestamp', () => { + expect(secondsToTimestamp(745)).toStrictEqual('12:25'); + }); + + it('does not add a leading zero to minutes when less than 10', () => { + expect(secondsToTimestamp(145)).toStrictEqual('2:25'); + }); + }); + }); + + describe('secondsToDurationTranslationKey', () => { + describe('when numSeconds is > 60 minutes', () => { + it('sets the ".hours.other" translation keys when hours is greater than 1', () => { + const actualKey = secondsToDurationTranslationKey(7745); + const expectedKey = '.hours.other'; + + expect(actualKey).toContain(expectedKey); + }); + + it('sets the ".hours.one" translation keys when hours equals 1', () => { + const actualKey = secondsToDurationTranslationKey(3745); + const expectedKey = '.hours.one'; + + expect(actualKey).toContain(expectedKey); + }); + + it('sets the ".minutesAndSeconds" translation key when minutes and seconds are both greater than 1', () => { + const actualKey = secondsToDurationTranslationKey(3745); + const expectedKey = '.minutesAndSeconds'; + + expect(actualKey).toContain(expectedKey); + }); + + it('sets the ".minutesAndSecond" translation key when minutes is greater than 1 and seconds are equal to 1', () => { + const actualKey = secondsToDurationTranslationKey(3721); + const expectedKey = '.minutesAndSecond'; + + expect(actualKey).toContain(expectedKey); + }); + + it('sets the ".minuteAndSeconds" translation key when minutes equals 1 and seconds is greater than 1', () => { + const actualKey = secondsToDurationTranslationKey(3680); + const expectedKey = '.minuteAndSeconds'; + + expect(actualKey).toContain(expectedKey); + }); + + it('sets the ".minuteAndSecond" translation key when minutes and seconds are both equal to 1', () => { + const actualKey = secondsToDurationTranslationKey(3661); + const expectedKey = '.minuteAndSecond'; + + expect(actualKey).toContain(expectedKey); + }); + + it('sets the ".andMinutes" translation key when there are zero seconds and minutes is greater than 1', () => { + const actualKey = secondsToDurationTranslationKey(3720); + const expectedKey = '.andMinutes'; + + expect(actualKey).toContain(expectedKey); + }); + + it('sets the ".andMinute" translation key when there are zero seconds and minutes equals 1', () => { + const actualKey = secondsToDurationTranslationKey(3660); + const expectedKey = '.andMinute'; + + expect(actualKey).toContain(expectedKey); + }); + + it('sets the ".andSeconds" translation key when there are zero minutes and seconds is greater than 1', () => { + const actualKey = secondsToDurationTranslationKey(3608); + const expectedKey = '.andSeconds'; + + expect(actualKey).toContain(expectedKey); + }); + + it('sets the ".andSecond" translation key when there are zero minutes and seconds equals 1', () => { + const actualKey = secondsToDurationTranslationKey(3601); + const expectedKey = '.andSecond'; + + expect(actualKey).toContain(expectedKey); + }); + + it('sets the ".only" translation key when there are zero minutes and seconds', () => { + const actualKey = secondsToDurationTranslationKey(3600); + const expectedKey = '.only'; + + expect(actualKey).toContain(expectedKey); + }); + }); + + describe('when numSeconds is > 60 seconds and < 60 minutes', () => { + it('sets the ".minutes.other" translation key when minutes is greater than 1', () => { + const actualKey = secondsToDurationTranslationKey(145); + const expectedKey = '.minutes.other'; + + expect(actualKey).toContain(expectedKey); + }); + + it('sets the ".minutes.one" translation key when minutes equals 1', () => { + const actualKey = secondsToDurationTranslationKey(80); + const expectedKey = '.minutes.one'; + + expect(actualKey).toContain(expectedKey); + }); + + it('sets the ".andSeconds" translation key when seconds is greater than 1', () => { + const actualKey = secondsToDurationTranslationKey(145); + const expectedKey = '.andSeconds'; + + expect(actualKey).toContain(expectedKey); + }); + + it('sets the ".andSecond" translation key when seconds equals 1', () => { + const actualKey = secondsToDurationTranslationKey(61); + const expectedKey = '.andSecond'; + + expect(actualKey).toContain(expectedKey); + }); + + it('sets the ".only" translation key when there are zero seconds', () => { + const actualKey = secondsToDurationTranslationKey(120); + const expectedKey = '.only'; + + expect(actualKey).toContain(expectedKey); + }); + }); + + describe('when numSeconds is < 60 seconds', () => { + it('sets the ".seconds.other" translation key when seconds is greater than 1', () => { + const actualKey = secondsToDurationTranslationKey(45); + const expectedKey = '.seconds.other'; + + expect(actualKey).toContain(expectedKey); + }); + + it('sets the ".seconds.one" translation key when seconds equals 1', () => { + const actualKey = secondsToDurationTranslationKey(1); + const expectedKey = '.seconds.one'; + + expect(actualKey).toContain(expectedKey); + }); + }); + }); +});