fronts-client/src/components/card/Card.tsx (482 lines of code) (raw):

import { Dispatch } from 'types/Store'; import React from 'react'; import { connect } from 'react-redux'; import Article from 'components/card/article/ArticleCard'; import type { State } from 'types/State'; import { createSelectCardType } from 'selectors/cardSelectors'; import { selectCard, selectExternalArticleFromCard, selectSupportingArticleCount, } from 'selectors/shared'; import { CardSizes, CardMeta } from 'types/Collection'; import SnapLink from 'components/card/snapLink/SnapLinkCard'; import { copyCardImageMetaWithPersist, addCardToClipboard, } from 'actions/Cards'; import { dragEventHasImageData, getMaybeDimensionsFromWidthAndHeight, validateDimensions, validateImageEvent, validateSlideshowDimensions, ValidationResponse, } from 'util/validateImageSrc'; import { editionsCardImageCriteria, DRAG_DATA_CARD_IMAGE_OVERRIDE, COLLECTIONS_USING_PORTRAIT_TRAILS, landScapeCardImageCriteria, portraitCardImageCriteria, defaultCardTrailImageCriteria, landscape5To4CardImageCriteria, COLLECTIONS_USING_LANDSCAPE_5_TO_4_TRAILS, squareImageCriteria, COLLECTIONS_USING_SQUARE_TRAILS, } from 'constants/image'; import Sublinks from '../FrontsEdit/CollectionComponents/Sublinks'; import { selectIsCardFormOpen, editorClearCardSelection, } from 'bundles/frontsUI'; import { bindActionCreators } from 'redux'; import ArticleMetaForm from '../form/ArticleMetaForm'; import { EditMode } from 'types/EditMode'; import { selectEditMode } from 'selectors/pathSelectors'; import { events } from 'services/GA'; import EditModeVisibility from 'components/util/EditModeVisibility'; import { css, styled } from 'constants/theme'; import { getPillarColor } from 'util/getPillarColor'; import { isLive as isArticleLive } from 'util/CAPIUtils'; import { DefaultDropIndicator } from 'components/DropZone'; import DragIntentContainer from 'components/DragIntentContainer'; import { CardTypes, CardTypesMap } from 'constants/cardTypes'; import { RecipeCard } from 'components/card/recipe/RecipeCard'; import { ChefCard } from 'components/card/chef/ChefCard'; import { ChefMetaForm } from '../form/ChefMetaForm'; import { FeastCollectionCard } from './feastCollection/FeastCollectionCard'; import { FeastCollectionMetaForm } from 'components/form/FeastCollectionMetaForm'; import { selectCollectionType } from 'selectors/frontsSelectors'; import { Criteria } from 'types/Grid'; import { Card as CardType } from 'types/Collection'; export const createCardId = (id: string) => `collection-item-${id}`; const CardContainer = styled('div')<{ pillarId: string | undefined; isLive?: boolean; size?: CardSizes; }>` border-top-width: 1px; border-top-style: solid; border-top-color: ${({ size, pillarId, isLive, theme }) => size !== 'small' && pillarId && isLive ? getPillarColor(pillarId, isLive) : theme.base.colors.borderColor}; `; const DropzoneStyling = styled.div<{ isDraggingCardOver?: boolean; }>` ${({ isDraggingCardOver }) => isDraggingCardOver && css` ${DefaultDropIndicator} { opacity: 1; } `} `; interface ContainerProps { uuid: string; frontId: string; collectionId?: string; children?: React.ReactNode; getNodeProps: () => object; onSelect: (uuid: string) => void; onDelete: () => void; parentId: string; size?: CardSizes; textSize?: CardSizes; isUneditable?: boolean; showMeta?: boolean; isSupporting?: boolean; canDragImage?: boolean; canShowPageViewData: boolean; updateCardMeta: (id: string, meta: CardMeta) => void; addImageToCard: (uuid: string, imageData: ValidationResponse) => void; } type CardContainerProps = ContainerProps & { onAddToClipboard: (uuid: string) => void; copyCardImageMeta: (from: string, to: string) => void; addImageToCard: (id: string, response: ValidationResponse) => void; clearCardSelection: (id: string) => void; type?: CardTypes; isSelected: boolean; numSupportingArticles: number; editMode: EditMode; isLive?: boolean; pillarId?: string; collectionType?: string; selectOtherCard: { (uuid: string): CardType }; groupSizeId?: number; }; class Card extends React.Component<CardContainerProps> { public state = { showCardSublinks: false, isDraggingCardOver: false, }; public toggleShowArticleSublinks = (e?: React.MouseEvent) => { const togPos = !this.state.showCardSublinks; this.setState({ showCardSublinks: togPos }); if (e) { e.stopPropagation(); } }; public render() { const { uuid, isSelected, isSupporting = false, children, getNodeProps, onSelect, type, size = 'default', textSize, isUneditable, numSupportingArticles, clearCardSelection, parentId, showMeta, frontId, collectionId, canDragImage, canShowPageViewData = false, isLive, pillarId, collectionType, groupSizeId, updateCardMeta, } = this.props; const getSublinks = ( <Sublinks numSupportingArticles={numSupportingArticles} toggleShowArticleSublinks={this.toggleShowArticleSublinks} showArticleSublinks={this.state.showCardSublinks} parentId={parentId} /> ); const getCard = () => { switch (type) { case CardTypesMap.ARTICLE: return ( <Article frontId={frontId} collectionId={collectionId} id={uuid} isUneditable={isUneditable} {...getNodeProps()} onDelete={this.onDelete} onAddToClipboard={this.handleAddToClipboard} onClick={isUneditable ? undefined : () => onSelect(uuid)} size={size} textSize={textSize} showMeta={showMeta} onImageDrop={this.handleImageDrop} canDragImage={canDragImage} canShowPageViewData={canShowPageViewData} imageCriteria={this.determineCardCriteria()} collectionType={collectionType} groupIndex={groupSizeId} > <EditModeVisibility visibleMode="fronts"> {getSublinks} {/* If there are no supporting articles, the children still need to be rendered, because the dropzone is a child */} {numSupportingArticles === 0 ? children : this.state.showCardSublinks && children} </EditModeVisibility> </Article> ); case CardTypesMap.SNAP_LINK: return ( <> <SnapLink frontId={frontId} collectionId={collectionId} id={uuid} isUneditable={isUneditable} {...getNodeProps()} onDelete={this.onDelete} onAddToClipboard={this.handleAddToClipboard} onClick={isUneditable ? undefined : () => onSelect(uuid)} size={size} textSize={textSize} showMeta={showMeta} canShowPageViewData={canShowPageViewData} /> {getSublinks} {numSupportingArticles === 0 ? children : this.state.showCardSublinks && children} </> ); case CardTypesMap.RECIPE: return ( <> <RecipeCard frontId={frontId} collectionId={collectionId} id={uuid} isUneditable={isUneditable} {...getNodeProps()} onDelete={this.onDelete} onAddToClipboard={this.handleAddToClipboard} /* No need for an OnClick here - there are no editable forms */ size={size} textSize={textSize} showMeta={showMeta} /> {getSublinks} </> ); case CardTypesMap.CHEF: return ( <> <ChefCard frontId={frontId} collectionId={collectionId} id={uuid} isUneditable={isUneditable} {...getNodeProps()} onDelete={this.onDelete} onAddToClipboard={this.handleAddToClipboard} // Chef has overrides so we need to edit it onClick={isUneditable ? undefined : () => onSelect(uuid)} size={size} textSize={textSize} showMeta={showMeta} /> </> ); case CardTypesMap.FEAST_COLLECTION: return ( <> <FeastCollectionCard frontId={frontId} collectionId={collectionId} id={uuid} isUneditable={isUneditable} {...getNodeProps()} onDelete={this.onDelete} onAddToClipboard={this.handleAddToClipboard} onClick={isUneditable ? undefined : () => onSelect(uuid)} size={size} textSize={textSize} showMeta={showMeta} /> <Sublinks numSupportingArticles={numSupportingArticles} toggleShowArticleSublinks={this.toggleShowArticleSublinks} showArticleSublinks={this.state.showCardSublinks} parentId={parentId} sublinkLabel="recipe/chef" /> {/* If there are no supporting articles, the children still need to be rendered, because the dropzone is a child */} {numSupportingArticles === 0 ? children : this.state.showCardSublinks && children} </> ); default: return ( <p> Item with id {uuid} has unknown card type {type} </p> ); } }; const getCardForm = () => { switch (type) { case CardTypesMap.CHEF: return ( <ChefMetaForm cardId={uuid} key={uuid} form={uuid} onSave={(meta) => { updateCardMeta(uuid, meta); clearCardSelection(uuid); }} onCancel={() => clearCardSelection(uuid)} size={size} /> ); case CardTypesMap.FEAST_COLLECTION: return ( <FeastCollectionMetaForm cardId={uuid} key={uuid} form={uuid} onSave={(meta) => { updateCardMeta(uuid, meta); clearCardSelection(uuid); }} onCancel={() => clearCardSelection(uuid)} size={size} /> ); default: return ( <ArticleMetaForm cardId={uuid} isSupporting={isSupporting} key={uuid} form={uuid} frontId={frontId} onSave={(meta) => { updateCardMeta(uuid, meta); clearCardSelection(uuid); }} onCancel={() => clearCardSelection(uuid)} size={size} groupSizeId={groupSizeId} /> ); } }; const supportsForm = type !== 'recipe'; const shouldDisplayForm = isSelected && supportsForm; return ( <CardContainer id={createCardId(uuid)} size={size} isLive={isLive} pillarId={pillarId} > {shouldDisplayForm ? ( <> {getCardForm()} {getSublinks} {numSupportingArticles === 0 ? children : this.state.showCardSublinks && children} </> ) : ( <DragIntentContainer filterRegisterEvent={(e) => !dragEventHasImageData(e)} onDragIntentStart={() => this.setIsDraggingCardOver(true)} onDragIntentEnd={() => this.setIsDraggingCardOver(false)} > <DropzoneStyling isDraggingCardOver={this.state.isDraggingCardOver}> {getCard()} </DropzoneStyling> </DragIntentContainer> )} </CardContainer> ); } public setIsDraggingCardOver = (isDraggingCardOver: boolean) => this.setState({ isDraggingCardOver }); private handleAddToClipboard = () => { this.props.onAddToClipboard(this.props.uuid); }; private onDelete = () => { this.props.onDelete(); }; private handleImageDrop = (e: React.DragEvent<HTMLElement>) => { events.imageAdded(this.props.frontId, 'drop-into-card'); 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; } // Our drag contains Grid data validateImageEvent(e, this.props.frontId, imageCriteria) .then((imageData) => this.props.addImageToCard(this.props.uuid, imageData), ) .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 (!collectionType) { return landScapeCardImageCriteria; } if (COLLECTIONS_USING_LANDSCAPE_5_TO_4_TRAILS.includes(collectionType)) { return landscape5To4CardImageCriteria; } if (COLLECTIONS_USING_SQUARE_TRAILS.includes(collectionType)) { return squareImageCriteria; } return COLLECTIONS_USING_PORTRAIT_TRAILS.includes(collectionType) ? portraitCardImageCriteria : landScapeCardImageCriteria; }; private checkDraggedImage = ( cardUuid: string, imageCriteria: Criteria, ): ReturnType<typeof validateDimensions> => { // check dragged image matches this card's collection's criteria. const cardImageWasDraggedFrom = this.props.selectOtherCard(cardUuid); const { imageSlideshowReplace, slideshow } = cardImageWasDraggedFrom.meta; if (imageSlideshowReplace) { return validateSlideshowDimensions(slideshow, imageCriteria); } 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, collectionId }: ContainerProps) => { const maybeExternalArticle = selectExternalArticleFromCard(state, uuid); return { type: selectType(state, uuid), isSelected: selectIsCardFormOpen(state, uuid, frontId), isLive: maybeExternalArticle && isArticleLive(maybeExternalArticle), pillarId: maybeExternalArticle && maybeExternalArticle.pillarId, numSupportingArticles: selectSupportingArticleCount(state, uuid), editMode: selectEditMode(state), collectionType: collectionId && selectCollectionType(state, collectionId), selectOtherCard: (uuid: string) => selectCard(state, uuid), }; }; }; const mapDispatchToProps = (dispatch: Dispatch) => { return bindActionCreators( { onAddToClipboard: addCardToClipboard, copyCardImageMeta: copyCardImageMetaWithPersist, clearCardSelection: editorClearCardSelection, }, dispatch, ); }; export default connect(createMapStateToProps, mapDispatchToProps)(Card);