diff --git a/app/model/FeatureSwitches.scala b/app/model/FeatureSwitches.scala index 0383ee28ca6..bfdb2351412 100644 --- a/app/model/FeatureSwitches.scala +++ b/app/model/FeatureSwitches.scala @@ -36,8 +36,14 @@ object TenImageSlideshows extends FeatureSwitch( enabled = false ) +object UsePortraitCropsForSomeCollectionTypes extends FeatureSwitch( + key = "support-portrait-crops", + title = "Use portrait crops for the experimental big card containers", + enabled = false +) + object FeatureSwitches { - val all: List[FeatureSwitch] = List(ObscureFeed, PageViewDataVisualisation, ShowFirefoxPrompt, TenImageSlideshows) + val all: List[FeatureSwitch] = List(ObscureFeed, PageViewDataVisualisation, ShowFirefoxPrompt, TenImageSlideshows, UsePortraitCropsForSomeCollectionTypes) def updateFeatureSwitchesForUser(userDataSwitches: Option[List[FeatureSwitch]], switch: FeatureSwitch): List[FeatureSwitch] = { val newSwitches = userDataSwitches match { diff --git a/fronts-client/src/components/CollectionDisplay.tsx b/fronts-client/src/components/CollectionDisplay.tsx index 75c8905b386..d659d179961 100644 --- a/fronts-client/src/components/CollectionDisplay.tsx +++ b/fronts-client/src/components/CollectionDisplay.tsx @@ -33,6 +33,11 @@ import { theme } from 'constants/theme'; import Button from 'components/inputs/ButtonDefault'; import { updateCollection as updateCollectionAction } from '../actions/Collections'; import { isMode } from '../selectors/pathSelectors'; +import { + COLLECTIONS_USING_PORTRAIT_TRAILS, + SUPPORT_PORTRAIT_CROPS, +} from 'constants/image'; +import { CropIcon } from './icons/Icons'; export const createCollectionId = ({ id }: Collection, frontId: string) => `front-${frontId}-collection-${id}`; @@ -254,6 +259,12 @@ class CollectionDisplay extends React.Component { const itemCount = articleIds ? articleIds.length : 0; const targetedTerritory = collection ? collection.targetedTerritory : null; const { displayName } = this.state; + + const usePortrait = + SUPPORT_PORTRAIT_CROPS && + collection?.type && + COLLECTIONS_USING_PORTRAIT_TRAILS.includes(collection?.type); + return ( { > + {usePortrait && ( + + )} {this.state.editingContainerName ? ( void; } +const STAGE = pageConfig.env; + +// We don't yet have any collectionTypes that use portrait crops +// but even when we do, we might not want to show the option on PROD +// it might lead to some broken visuals if used before implemented +// on platforms. +const SWITCHES_TO_HIDE_ON_PROD = ['support-portrait-crops']; + +const filterSwitchesByStage = (featureSwitch: FeatureSwitch): boolean => + STAGE === 'code' || !SWITCHES_TO_HIDE_ON_PROD.includes(featureSwitch.key); + class FeaturesForm extends React.Component { public render() { const { featureSwitches } = this.props; + console.log(featureSwitches); return (
- {featureSwitches.map((featureSwitch) => ( + {featureSwitches.filter(filterSwitchesByStage).map((featureSwitch) => ( `collection-item-${id}`; @@ -104,6 +114,8 @@ type CardContainerProps = ContainerProps & { editMode: EditMode; isLive?: boolean; pillarId?: string; + collectionType?: string; + selectOtherCard: { (uuid: string): CardType }; }; class Card extends React.Component { @@ -336,18 +348,35 @@ class Card extends React.Component { e.preventDefault(); e.persist(); + const isEditionsMode = this.props.editMode === 'editions'; + const imageCriteria = isEditionsMode + ? editionsCardImageCriteria + : this.determineCardCriteria(); + // Our drag is a copy event, from another Card const cardUuid = e.dataTransfer.getData(DRAG_DATA_CARD_IMAGE_OVERRIDE); if (cardUuid) { + if (!isEditionsMode) { + // check dragged image matches this card's collection's criteria. + const validationForDraggedImage = this.checkDraggedImage( + cardUuid, + imageCriteria + ); + if (!validationForDraggedImage.matchesCriteria) { + // @todo - if they don't match, check grid for a matching + // crop of the image and use that if present? + // @todo handle error + alert( + `Cannot copy that image to this card: ${validationForDraggedImage.reason}` + ); + return; + } + } + this.props.copyCardImageMeta(cardUuid, this.props.uuid); return; } - const isEditionsMode = this.props.editMode === 'editions'; - const imageCriteria = isEditionsMode - ? editionsCardImageCriteria - : defaultCardTrailImageCriteria; - // Our drag contains Grid data validateImageEvent(e, this.props.frontId, imageCriteria) .then((imageData) => @@ -355,11 +384,54 @@ class Card extends React.Component { ) .catch(alert); }; + + private determineCardCriteria = (): Criteria => { + const { collectionType, parentId } = this.props; + // @todo - how best to handle crop drags to a clipboard card? + // Using the default (landscape) for now. + // But, if you set a replacement (lanscape) trail on a clipboard + // item, that item can't be dragged to a portrit collection. + // Ideally, handleImageDrop will check if the Image has a matching + // crop of the required criteria and use that instead of the crop + // being dragged (or the crop on the card being dragged) onto the card + if (parentId === 'clipboard') { + return defaultCardTrailImageCriteria; + } + + if (!SUPPORT_PORTRAIT_CROPS || !collectionType) { + return landScapeCardImageCriteria; + } + + return COLLECTIONS_USING_PORTRAIT_TRAILS.includes(collectionType) + ? portraitCardImageCriteria + : landScapeCardImageCriteria; + }; + + private checkDraggedImage = ( + cardUuid: string, + imageCriteria: Criteria + ): ReturnType => { + // check dragged image matches this card's collection's criteria. + const cardImageWasDraggedFrom = this.props.selectOtherCard(cardUuid); + + const draggedImageDims = getMaybeDimensionsFromWidthAndHeight( + cardImageWasDraggedFrom?.meta?.imageSrcWidth, + cardImageWasDraggedFrom?.meta?.imageSrcHeight + ); + + if (!draggedImageDims) { + return { + matchesCriteria: false, + reason: 'no replacement image found', + }; + } + return validateDimensions(draggedImageDims, imageCriteria); + }; } const createMapStateToProps = () => { const selectType = createSelectCardType(); - return (state: State, { uuid, frontId }: ContainerProps) => { + return (state: State, { uuid, frontId, collectionId }: ContainerProps) => { const maybeExternalArticle = selectExternalArticleFromCard(state, uuid); return { type: selectType(state, uuid), @@ -368,6 +440,8 @@ const createMapStateToProps = () => { pillarId: maybeExternalArticle && maybeExternalArticle.pillarId, numSupportingArticles: selectSupportingArticleCount(state, uuid), editMode: selectEditMode(state), + collectionType: collectionId && selectCollectionType(state, collectionId), + selectOtherCard: (uuid: string) => selectCard(state, uuid), }; }; }; diff --git a/fronts-client/src/components/card/article/ArticleBody.tsx b/fronts-client/src/components/card/article/ArticleBody.tsx index 666b4191c85..41135de3b77 100644 --- a/fronts-client/src/components/card/article/ArticleBody.tsx +++ b/fronts-client/src/components/card/article/ArticleBody.tsx @@ -34,6 +34,7 @@ import EditModeVisibility from 'components/util/EditModeVisibility'; import PageViewDataWrapper from '../../PageViewDataWrapper'; import ImageAndGraphWrapper from 'components/image/ImageAndGraphWrapper'; import { getPaths } from 'util/paths'; +import { getMaybeDimensionsFromWidthAndHeight } from 'util/validateImageSrc'; const ThumbnailPlaceholder = styled(BasePlaceholder)` flex-shrink: 0; @@ -131,6 +132,8 @@ interface ArticleBodyProps { canShowPageViewData: boolean; frontId: string; collectionId?: string; + imageSrcWidth?: string; + imageSrcHeight?: string; } const articleBodyDefault = React.memo( @@ -176,11 +179,22 @@ const articleBodyDefault = React.memo( collectionId, newspaperPageNumber, promotionMetric, + imageSrcWidth, + imageSrcHeight, }: ArticleBodyProps) => { const displayByline = size === 'default' && showByline && byline; const now = Date.now(); const paths = urlPath ? getPaths(urlPath) : undefined; + const thumbnailDims = getMaybeDimensionsFromWidthAndHeight( + imageSrcWidth, + imageSrcHeight + ); + const thumbnailIsPortrait = + !!imageReplace && + thumbnailDims && + thumbnailDims.height > thumbnailDims.width; + return ( <> {showMeta && ( @@ -294,6 +308,7 @@ const articleBodyDefault = React.memo( imageHide={imageHide} url={thumbnail} isDraggingImageOver={isDraggingImageOver} + isPortrait={thumbnailIsPortrait} > {cutoutThumbnail ? ( diff --git a/fronts-client/src/components/form/ArticleMetaForm.tsx b/fronts-client/src/components/form/ArticleMetaForm.tsx index 536c093c921..021cfdea1d9 100644 --- a/fronts-client/src/components/form/ArticleMetaForm.tsx +++ b/fronts-client/src/components/form/ArticleMetaForm.tsx @@ -46,7 +46,9 @@ import { editionsCardImageCriteria, editionsMobileCardImageCriteria, editionsTabletCardImageCriteria, - defaultCardTrailImageCriteria, + portraitCardImageCriteria, + COLLECTIONS_USING_PORTRAIT_TRAILS, + SUPPORT_PORTRAIT_CROPS, } from 'constants/image'; import { selectors as collectionSelectors } from 'bundles/collectionsBundle'; import { getContributorImage } from 'util/CAPIUtils'; @@ -64,6 +66,9 @@ import { FormContainer } from 'components/form/FormContainer'; import { FormContent } from 'components/form/FormContent'; import { TextOptionsInputGroup } from 'components/form/TextOptionsInputGroup'; import { FormButtonContainer } from 'components/form/FormButtonContainer'; +import { selectCollectionType } from 'selectors/frontsSelectors'; +import { Criteria } from 'types/Grid'; + interface ComponentProps extends ContainerProps { articleExists: boolean; @@ -81,6 +86,7 @@ interface ComponentProps extends ContainerProps { coverCardTabletImage?: ImageData; size?: string; isEmailFronts?: boolean; + collectionType?: string; } type Props = ComponentProps & @@ -91,6 +97,7 @@ type RenderSlideshowProps = WrappedFieldArrayProps & { frontId: string; change: (field: string, value: any) => void; slideshowHasAtLeastTwoImages: boolean; + criteria: Criteria; }; const RowContainer = styled.div` @@ -227,6 +234,7 @@ const RenderSlideshow = ({ frontId, change, slideshowHasAtLeastTwoImages, + criteria, }: RenderSlideshowProps) => { const [slideshowIndex, setSlideshowIndex] = React.useState(0); @@ -285,8 +293,7 @@ const RenderSlideshow = ({ name={name} component={InputImage} small - // TO DO - will slideshows always be landscape? - criteria={landScapeCardImageCriteria} + criteria={criteria} frontId={frontId} isSelected={index === slideshowIndex} isInvalid={isInvalidCaptionLength(index)} @@ -684,7 +691,7 @@ class FormComponent extends React.Component { criteria={ isEditionsMode ? editionsCardImageCriteria - : defaultCardTrailImageCriteria + : this.determineCardCriteria() } frontId={frontId} defaultImageUrl={ @@ -698,6 +705,7 @@ class FormComponent extends React.Component { } hasVideo={hasMainVideo} onChange={this.handleImageChange} + collectionType={this.props.collectionType} /> @@ -793,6 +801,7 @@ class FormComponent extends React.Component { frontId={frontId} component={RenderSlideshow} change={change} + criteria={this.determineCardCriteria()} slideshowHasAtLeastTwoImages={slideshowHasAtLeastTwoImages} /> @@ -932,6 +941,16 @@ class FormComponent extends React.Component { */ private getHeadlineLabel = () => this.props.snapType === 'html' ? 'Content' : 'Headline'; + + private determineCardCriteria = (): Criteria => { + const { collectionType } = this.props; + if (!SUPPORT_PORTRAIT_CROPS || !collectionType) { + return landScapeCardImageCriteria; + } + return COLLECTIONS_USING_PORTRAIT_TRAILS.includes(collectionType) + ? portraitCardImageCriteria + : landScapeCardImageCriteria; + }; } const CardForm = reduxForm({ @@ -1014,11 +1033,12 @@ const createMapStateToProps = () => { } const isEmailFronts = selectV2SubPath(state) === '/email'; + const collectionId = (parentCollection && parentCollection.id) || null; return { articleExists: !!article, hasMainVideo: !!article && !!article.hasMainVideo, - collectionId: (parentCollection && parentCollection.id) || null, + collectionId, getLastUpdatedBy, snapType: article && article.snapType, initialValues: getInitialValuesForCardForm(article), @@ -1045,6 +1065,9 @@ const createMapStateToProps = () => { coverCardTabletImage: valueSelector(state, 'coverCardTabletImage'), pickedKicker: !!article ? article.pickedKicker : undefined, isEmailFronts, + collectionType: collectionId + ? selectCollectionType(state, collectionId) + : undefined, }; }; }; diff --git a/fronts-client/src/components/icons/Icons.tsx b/fronts-client/src/components/icons/Icons.tsx index 2dd05cf5bb9..84d18c95f13 100644 --- a/fronts-client/src/components/icons/Icons.tsx +++ b/fronts-client/src/components/icons/Icons.tsx @@ -318,6 +318,28 @@ const WarningIcon = ({ fill = theme.colors.white, size = 'm' }: IconProps) => ( ); +const CropIcon = ({ + fill = theme.colors.greyDark, + size = 'xl', + title = null, +}: IconProps) => ( + + {title} + + +); + export { DownCaretIcon, RubbishBinIcon, @@ -334,4 +356,5 @@ export { VideoIcon, DragHandleIcon as DragIcon, WarningIcon, + CropIcon, }; diff --git a/fronts-client/src/components/image/ImageInputImageContainer.tsx b/fronts-client/src/components/image/ImageInputImageContainer.tsx new file mode 100644 index 00000000000..f5cbea90713 --- /dev/null +++ b/fronts-client/src/components/image/ImageInputImageContainer.tsx @@ -0,0 +1,64 @@ +import { styled } from 'constants/theme'; + +const PORTRAIT_RATIO = 5 / 4; +const NORMAL_PORTRAIT_WIDTH = 160; +const SMALL_PORTRAIT_WIDTH = 60; +const TEXTINPUT_HEIGHT = 30; + +const smallPortaitStyle = ` + width: ${SMALL_PORTRAIT_WIDTH}px; + height: ${Math.floor(SMALL_PORTRAIT_WIDTH * PORTRAIT_RATIO)}px; + padding: 40% 0; + min-width: 50px; + margin: 0 auto; +`; + +const normalPortraitStyle = ` + width: ${NORMAL_PORTRAIT_WIDTH}px; + height: ${Math.floor( + NORMAL_PORTRAIT_WIDTH * PORTRAIT_RATIO + TEXTINPUT_HEIGHT + )}px; + `; + +const smallLandscapeStyle = ` + width: 100%; + maxWidth: 180px; + height: 0; + padding: 40%; + minWidth: 50px; +`; + +const normalLandscapeStyle = ` + width: 100%; + maxWidth: 180px; + height: 115px; +`; + +const getVariableImageContainerStyle = ({ + portrait = false, + small = false, +}: { + small?: boolean; + portrait?: boolean; +}) => + portrait + ? small + ? smallPortaitStyle + : normalPortraitStyle + : small + ? smallLandscapeStyle + : normalLandscapeStyle; + +// assuming any portrait image (ie height>width) +// is in the 4:5 ratio for purposes of styling +// the image container +export const ImageInputImageContainer = styled.div<{ + small?: boolean; + portrait?: boolean; +}>` + display: flex; + flex-direction: column; + position: relative; + transition: background-color 0.15s; + ${getVariableImageContainerStyle} +`; diff --git a/fronts-client/src/components/image/Thumbnail.tsx b/fronts-client/src/components/image/Thumbnail.tsx index e52604c8264..404a7ad495d 100644 --- a/fronts-client/src/components/image/Thumbnail.tsx +++ b/fronts-client/src/components/image/Thumbnail.tsx @@ -9,6 +9,7 @@ const ThumbnailSmall = styled(ThumbnailBase)<{ url?: string | void; isDraggingImageOver?: boolean; imageHide?: boolean; + isPortrait?: boolean; }>` position: relative; width: ${theme.thumbnailImage.width}; @@ -19,6 +20,15 @@ const ThumbnailSmall = styled(ThumbnailBase)<{ font-weight: bold; opacity: ${({ imageHide }) => (imageHide && imageHide ? '0.5' : '1')}; background-image: ${({ url }) => `url('${url}')`}; + + ${({ isPortrait }) => + isPortrait && + ` + background-size: contain; + background-repeat: no-repeat; + background-position-x: center; + `} + ${({ isDraggingImageOver }) => isDraggingImageOver && `background: ${theme.base.colors.dropZoneActiveStory}; diff --git a/fronts-client/src/components/inputs/InputImage.tsx b/fronts-client/src/components/inputs/InputImage.tsx index 52fc7a7f7f3..dbe8ae1cfa5 100644 --- a/fronts-client/src/components/inputs/InputImage.tsx +++ b/fronts-client/src/components/inputs/InputImage.tsx @@ -31,38 +31,12 @@ import { portraitCardImageCriteria, } from 'constants/image'; import ImageDragIntentIndicator from 'components/image/ImageDragIntentIndicator'; +import { ImageInputImageContainer as ImageContainer } from 'components/image/ImageInputImageContainer'; import { EditMode } from 'types/EditMode'; import { selectEditMode } from '../../selectors/pathSelectors'; import CircularIconContainer from '../icons/CircularIconContainer'; import { error } from '../../styleConstants'; -// assuming any portrait image (ie height>width) -// is in the 4:5 ratio for purposes of styling -// the image container -const ImageContainer = styled.div<{ - small?: boolean; - portrait?: boolean; -}>` - display: flex; - flex-direction: column; - position: relative; - width: 100%; - max-width: ${(props) => !props.small && '180px'}; - ${({ small }) => - small && - `min-width: 50px; - padding: 40%;`} - height: ${(props) => (props.small ? '0' : '115px')}; - transition: background-color 0.15s; - - ${({ portrait, small }) => - portrait && - ` - width: ${small ? 50 : 200}px; - height: ${small ? 62 : 250}px; - `} -`; - const AddImageButton = styled(ButtonDefault)<{ small?: boolean }>` background-color: ${({ small }) => small ? theme.colors.greyLight : `#5e5e5e50`}; @@ -242,6 +216,7 @@ export interface InputImageContainerProps { replaceImage: boolean; isSelected?: boolean; isInvalid?: boolean; + collectionType?: string; } type ComponentProps = InputImageContainerProps & @@ -273,7 +248,6 @@ class InputImage extends React.Component { const { src } = valueRecord ?? {}; const imageSrc = typeof src === 'string' ? src : ''; - this.state = { isDragging: false, modalOpen: false, diff --git a/fronts-client/src/constants/image.ts b/fronts-client/src/constants/image.ts index e3dfb2d7b46..447a83d034e 100644 --- a/fronts-client/src/constants/image.ts +++ b/fronts-client/src/constants/image.ts @@ -1,3 +1,10 @@ +import pageConfig from 'util/extractConfigFromPage'; + +export const SUPPORT_PORTRAIT_CROPS = + pageConfig?.userData?.featureSwitches.find( + (feature) => feature.key === 'support-portrait-crops' + )?.enabled || false; + export const landScapeCardImageCriteria = { minWidth: 400, widthAspectRatio: 5, @@ -10,6 +17,9 @@ export const portraitCardImageCriteria = { heightAspectRatio: 5, }; +// @todo - add the right collection type when it exists +export const COLLECTIONS_USING_PORTRAIT_TRAILS: string[] = []; + export const defaultCardTrailImageCriteria = landScapeCardImageCriteria; export const editionsCardImageCriteria = { diff --git a/fronts-client/src/selectors/frontsSelectors.ts b/fronts-client/src/selectors/frontsSelectors.ts index 6a6c0c7f56b..8094ac263de 100644 --- a/fronts-client/src/selectors/frontsSelectors.ts +++ b/fronts-client/src/selectors/frontsSelectors.ts @@ -147,6 +147,16 @@ const selectCollectionDisplayName = ( return !!collection ? collection.displayName : ''; }; +const selectCollectionType = ( + state: State, + collectionId: string +): string | undefined => { + const collection = selectCollection(state, { + collectionId, + }); + return collection?.type; +}; + const selectFrontsIds = createSelector([selectFronts], (fronts): string[] => { if (!fronts) { return []; @@ -363,6 +373,7 @@ export { selectCollectionHasPrefill, selectCollectionIsHidden, selectCollectionDisplayName, + selectCollectionType, selectFrontsConfig, selectCollectionConfigs, selectFrontsIds, diff --git a/fronts-client/src/util/__tests__/validateImageSrc.spec.ts b/fronts-client/src/util/__tests__/validateImageSrc.spec.ts index cf1b064c911..faab4b6c01b 100644 --- a/fronts-client/src/util/__tests__/validateImageSrc.spec.ts +++ b/fronts-client/src/util/__tests__/validateImageSrc.spec.ts @@ -213,7 +213,7 @@ describe('Validate images', () => { ).then( (err) => done.fail(err.toString()), (err) => { - expect(err.message).toMatch(/does not have a valid crop/i); + expect(err.message).toMatch(/does not have any valid crops/i); done(); } ); @@ -242,13 +242,13 @@ describe('Validate images', () => { { minWidth: 100, maxWidth: 1000, - widthAspectRatio: 5, + widthAspectRatio: 7, heightAspectRatio: 4, } ).then( (err) => done.fail(err.toString()), (err) => { - expect(err.message).toMatch(/does not have a valid crop/i); + expect(err.message).toMatch(/does not have a valid 7:4 crop/i); done(); } ); @@ -311,13 +311,13 @@ describe('Validate images', () => { ).then( (err) => done.fail(err.toString()), (err) => { - expect(err.message).toMatch(/does not have a valid crop/i); + expect(err.message).toMatch(/does not have any valid crops/i); done(); } ); }); - it("fails if crops don't respect criteria", (done) => { + it("fails if crops don't respect criteria with an aspect ratio", (done) => { ImageMock.defaultWidth = 140; ImageMock.defaultHeight = 140; grid.gridInstance.getImage = () => @@ -348,7 +348,41 @@ describe('Validate images', () => { ).then( (err) => done.fail(err.toString()), (err) => { - expect(err.message).toMatch(/does not have a valid crop/i); + expect(err.message).toMatch(/does not have a valid 5:4 crop/i); + done(); + } + ); + }); + it("fails if crops don't respect criteria without an aspect ration", (done) => { + ImageMock.defaultWidth = 140; + ImageMock.defaultHeight = 140; + grid.gridInstance.getImage = () => + Promise.resolve({ + data: { + exports: [ + { + id: 'image_crop', + assets: [ + { dimensions: { width: 900, height: 100 } }, + { dimensions: { width: 500, height: 10 } }, + { dimensions: { width: 50, height: 1 } }, + ], + }, + ], + }, + }); + + validateImageSrc( + 'http://grid.co.uk/1234567890123456789012345678901234567890', + 'front', + { + minWidth: 1000, + maxWidth: 2000, + } + ).then( + (err) => done.fail(err.toString()), + (err) => { + expect(err.message).toMatch(/does not have any valid crops/i); done(); } ); diff --git a/fronts-client/src/util/validateImageSrc.ts b/fronts-client/src/util/validateImageSrc.ts index 0a42b72f0ff..2af35c3e696 100644 --- a/fronts-client/src/util/validateImageSrc.ts +++ b/fronts-client/src/util/validateImageSrc.ts @@ -67,12 +67,17 @@ function getSuitableImageDetails( id: string, desired: Criteria ): Promise { + const { maxWidth, minWidth, widthAspectRatio, heightAspectRatio } = desired; if (crops.length === 0) { return Promise.reject( - new Error('The image does not have a valid crop on the Grid') + new Error( + typeof widthAspectRatio === 'number' && + typeof heightAspectRatio === 'number' + ? `The image does not have a valid ${widthAspectRatio}:${heightAspectRatio} crop on the Grid` + : `The image does not have any valid crops on the Grid` + ) ); } - const { maxWidth, minWidth } = desired; const assets = sortBy( [crops[0].master] .concat(crops[0].assets) @@ -115,39 +120,21 @@ function validateActualImage(image: ImageDescription, frontId?: string) { const { width = 0, height = 0, - ratio = 0, + ratio, criteria, path, origin, thumb, } = image; - const { - maxWidth, - minWidth, - widthAspectRatio, - heightAspectRatio, - }: Criteria = criteria || {}; - const criteriaRatio = - widthAspectRatio && heightAspectRatio - ? widthAspectRatio / heightAspectRatio - : NaN; - if (maxWidth && maxWidth < width) { - return reject( - new Error(`Images cannot be more than ${maxWidth} pixels wide`) - ); - } else if (minWidth && minWidth > width) { - return reject( - new Error(`Images cannot be less than ${minWidth} pixels wide`) - ); - } else if (criteriaRatio && Math.abs(criteriaRatio - ratio) > 0.01) { - return reject( - new Error( - `Images must have a ${widthAspectRatio || ''}:${ - heightAspectRatio || '' - } aspect ratio` - ) + if (criteria) { + const dimensionValidation = validateDimensions( + { width, height, ratio }, + criteria ); + if (dimensionValidation.matchesCriteria === false) { + return reject(new Error(dimensionValidation.reason)); + } } if (image.origin) { return recordUsage(image.origin.split('/').slice(-1)[0], frontId).then( @@ -185,11 +172,31 @@ function stripImplementationDetails( new Error(`There was a problem contacting The Grid - ${e.message}`) ) ) - .then((gridImageJson: string) => - filterGridCrops(gridImageJson, maybeFromGrid, criteria) - ) - .then((crops: Crop[]) => - getSuitableImageDetails(crops, maybeFromGrid.id, criteria || {}) + .then((gridImageJson: string) => ({ + crops: filterGridCrops(gridImageJson, maybeFromGrid, criteria), + areNoCropsOfAnySize: + filterGridCrops(gridImageJson, maybeFromGrid).length === 0, + })) + .then( + ({ + crops, + areNoCropsOfAnySize, + }: { + crops: Crop[]; + areNoCropsOfAnySize: boolean; + }) => { + if (crops.length === 0 && areNoCropsOfAnySize) { + return Promise.reject( + new Error('The image does not have any valid crops on the Grid') + ); + } + + return getSuitableImageDetails( + crops, + maybeFromGrid.id, + criteria || {} + ); + } ) .then((asset: ImageDescription) => resolve({ @@ -299,6 +306,56 @@ function validateImageEvent( ); } +const getMaybeDimensionsFromWidthAndHeight = ( + imageSrcWidth?: string | number, + imageSrcHeight?: string | number +) => { + if (!imageSrcHeight || !imageSrcWidth) { + return undefined; + } + const height = Number(imageSrcHeight); + const width = Number(imageSrcWidth); + return isNaN(height) || isNaN(width) ? undefined : { width, height }; +}; + +function validateDimensions( + dimensions: { width: number; height: number; ratio?: number }, + criteria: Criteria +): { matchesCriteria: true } | { matchesCriteria: false; reason: string } { + const { width, height } = dimensions; + const { maxWidth, minWidth, widthAspectRatio, heightAspectRatio } = criteria; + const criteriaRatio = + widthAspectRatio && heightAspectRatio + ? widthAspectRatio / heightAspectRatio + : NaN; + + // if validating a mediaItem with defined ratio value, use that instead of + // calculating from width and height + const ratio = + typeof dimensions.ratio == 'number' ? dimensions.ratio : width / height; + + if (maxWidth && maxWidth < width) { + return { + matchesCriteria: false, + reason: `Images cannot be more than ${maxWidth} pixels wide`, + }; + } else if (minWidth && minWidth > width) { + return { + matchesCriteria: false, + reason: `Images cannot be less than ${minWidth} pixels wide`, + }; + } else if (criteriaRatio && Math.abs(criteriaRatio - ratio) > 0.01) { + return { + matchesCriteria: false, + reason: `Images must have a ${widthAspectRatio || ''}:${ + heightAspectRatio || '' + } aspect ratio`, + }; + } + + return { matchesCriteria: true }; +} + const imageDropTypes = [ ...gridDropTypes, DRAG_DATA_CARD_IMAGE_OVERRIDE, @@ -316,4 +373,6 @@ export { validateImageSrc, validateImageEvent, validateMediaItem, + validateDimensions, + getMaybeDimensionsFromWidthAndHeight, };