From db3b502d25007091c91998af6f3ce6bc22b640a1 Mon Sep 17 00:00:00 2001 From: Gjore Milevski Date: Wed, 6 Nov 2024 13:18:30 +0100 Subject: [PATCH 1/2] Add ability to configure the card image/description separately from the hero --- .../common/card/horizontal-info-card.tsx | 38 +-- app/scripts/components/common/card/index.tsx | 137 +++++---- .../common/catalog/catalog-card.tsx | 177 +++++++---- .../common/featured-slider-section.tsx | 31 +- .../components/common/related-content.tsx | 17 +- .../components/home/featured-stories.tsx | 8 +- .../components/stories/hub/hub-content.tsx | 282 +++++++++--------- app/scripts/types/veda.ts | 6 + parcel-resolver-veda/index.d.ts | 4 + 9 files changed, 405 insertions(+), 295 deletions(-) diff --git a/app/scripts/components/common/card/horizontal-info-card.tsx b/app/scripts/components/common/card/horizontal-info-card.tsx index 51063dbcf..c4f748c1d 100644 --- a/app/scripts/components/common/card/horizontal-info-card.tsx +++ b/app/scripts/components/common/card/horizontal-info-card.tsx @@ -1,9 +1,6 @@ import React from 'react'; import styled, { css } from 'styled-components'; -import { - glsp, - themeVal, -} from '@devseed-ui/theme-provider'; +import { glsp, themeVal } from '@devseed-ui/theme-provider'; import { CardTitle } from './styles'; import { variableBaseType } from '$styles/variable-utils'; import { Pill } from '$styles/pill'; @@ -17,6 +14,8 @@ const CardImage = styled.div` min-width: 10rem; width: 10rem; height: 100%; + background: ${themeVal('color.primary-100')}; + img { width: 100%; height: 100%; @@ -48,8 +47,8 @@ export const HorizontalCardStyles = css` /* stylelint-disable-next-line value-no-vendor-prefix */ display: -webkit-box; -webkit-line-clamp: 2; /* number of lines to show */ - line-clamp: 2; - + line-clamp: 2; + /* stylelint-disable-next-line property-no-vendor-prefix */ -webkit-box-orient: vertical; } @@ -70,18 +69,12 @@ interface Props { } export default function HorizontalInfoCard(props: Props) { - const { - title, - description, - imgSrc, - imgAlt, - tagLabels, - } = props; + const { title, description, imgSrc, imgAlt, tagLabels } = props; return ( - {imgAlt} + {imgSrc && {imgAlt}} {title} @@ -89,17 +82,14 @@ export default function HorizontalInfoCard(props: Props) {

{description}

- { - tagLabels && ( - tagLabels.map((label) => ( - - {label} - - )) - ) - } + {tagLabels && + tagLabels.map((label) => ( + + {label} + + ))}
); -} \ No newline at end of file +} diff --git a/app/scripts/components/common/card/index.tsx b/app/scripts/components/common/card/index.tsx index 08e32a3bb..ce98fac0b 100644 --- a/app/scripts/components/common/card/index.tsx +++ b/app/scripts/components/common/card/index.tsx @@ -7,12 +7,21 @@ import { media, multiply, themeVal, - listReset, + listReset } from '@devseed-ui/theme-provider'; const SmartLink = lazy(() => import('../smart-link')); -import { CardBody, CardBlank, CardHeader, CardHeadline, CardTitle, CardOverline } from './styles'; -import HorizontalInfoCard, { HorizontalCardStyles } from './horizontal-info-card'; +import { + CardBody, + CardBlank, + CardHeader, + CardHeadline, + CardTitle, + CardOverline +} from './styles'; +import HorizontalInfoCard, { + HorizontalCardStyles +} from './horizontal-info-card'; import { variableBaseType, variableGlsp } from '$styles/variable-utils'; import { ElementInteractive } from '$components/common/element-interactive'; import { Figure } from '$components/common/figure'; @@ -175,15 +184,17 @@ const CardLabel = styled.span` } `; - const CardFigure = styled(Figure)` order: -1; + background: ${themeVal('color.primary-100')}; + min-height: ${variableGlsp(12)}; img { height: 100%; width: 100%; object-fit: cover; mix-blend-mode: multiply; + display: ${(props) => (props.src ? 'inline-block' : 'none')}; } `; @@ -259,8 +270,10 @@ export interface CardComponentProps extends CardComponentBaseProps { type CardComponentPropsType = CardComponentProps | CardComponentPropsDeprecated; // Type guard to check if props has linkProperties -function hasLinkProperties(props: CardComponentPropsType): props is CardComponentProps { - return !!((props as CardComponentProps).linkProperties); +function hasLinkProperties( + props: CardComponentPropsType +): props is CardComponentProps { + return !!(props as CardComponentProps).linkProperties; } function CardComponent(props: CardComponentPropsType) { @@ -280,8 +293,8 @@ function CardComponent(props: CardComponentPropsType) { hideExternalLinkBadge, onCardClickCapture } = props; -// @TODO: This process is not necessary once all the instances adapt the linkProperties syntax -// Consolidate them to use LinkProperties only + // @TODO: This process is not necessary once all the instances adapt the linkProperties syntax + // Consolidate them to use LinkProperties only let linkProperties: LinkWithPathProperties; if (hasLinkProperties(props)) { @@ -313,59 +326,60 @@ function CardComponent(props: CardComponentPropsType) { linkLabel={linkLabel ?? 'View more'} onClickCapture={onCardClickCapture} > - { - cardType !== 'horizontal-info' && ( - <> - - - {title} - - {(hideExternalLinkBadge !== true && isExternalLink) && } - {!isExternalLink && tagLabels && parentTo && ( - tagLabels.map((label) => ( - - {label} - - )) - )} - {date ? ( - <> - published on{' '} - - - ) : ( - overline - )} - - - - {description && ( - -

{description}

-
- )} - {footerContent && {footerContent}} - {imgSrc && ( - - {imgAlt} - - )} - - ) - } - { - cardType === 'horizontal-info' && ( - - ) - } + {cardType !== 'horizontal-info' && ( + <> + + + {title} + + {hideExternalLinkBadge !== true && isExternalLink && ( + + )} + {!isExternalLink && + tagLabels && + parentTo && + tagLabels.map((label) => ( + + {label} + + ))} + {date ? ( + <> + published on{' '} + + + ) : ( + overline + )} + + + + {description && ( + +

{description}

+
+ )} + {footerContent && {footerContent}} + + {imgAlt} + + + )} + {cardType === 'horizontal-info' && ( + + )} ); } @@ -373,4 +387,3 @@ function CardComponent(props: CardComponentPropsType) { export const Card = styled(CardComponent)` /* Convert to styled-component: https://styled-components.com/docs/advanced#caveat */ `; - diff --git a/app/scripts/components/common/catalog/catalog-card.tsx b/app/scripts/components/common/catalog/catalog-card.tsx index 427ca9ba8..a941191cb 100644 --- a/app/scripts/components/common/catalog/catalog-card.tsx +++ b/app/scripts/components/common/catalog/catalog-card.tsx @@ -1,17 +1,26 @@ -import React from "react"; -import styled, { css } from "styled-components"; -import { CollecticonPlus, CollecticonTickSmall, iconDataURI } from "@devseed-ui/collecticons"; -import { glsp, themeVal } from "@devseed-ui/theme-provider"; +import React from 'react'; +import styled, { css } from 'styled-components'; +import { + CollecticonPlus, + CollecticonTickSmall, + iconDataURI +} from '@devseed-ui/collecticons'; +import { glsp, themeVal } from '@devseed-ui/theme-provider'; -import { Card, LinkProperties } from "../card"; -import { CardMeta, CardTopicsList } from "../card/styles"; -import { DatasetClassification } from "../dataset-classification"; -import { CardSourcesList } from "../card-sources"; -import TextHighlight from "../text-highlight"; -import { DatasetData, DatasetLayer } from "$types/veda"; -import { getDatasetPath } from "$utils/routes"; -import { TAXONOMY_SOURCE, TAXONOMY_TOPICS, getAllTaxonomyValues, getTaxonomy } from "$utils/veda-data/taxonomies"; -import { Pill } from "$styles/pill"; +import { Card, LinkProperties } from '../card'; +import { CardMeta, CardTopicsList } from '../card/styles'; +import { DatasetClassification } from '../dataset-classification'; +import { CardSourcesList } from '../card-sources'; +import TextHighlight from '../text-highlight'; +import { DatasetData, DatasetLayer } from '$types/veda'; +import { getDatasetPath } from '$utils/routes'; +import { + TAXONOMY_SOURCE, + TAXONOMY_TOPICS, + getAllTaxonomyValues, + getTaxonomy +} from '$utils/veda-data/taxonomies'; +import { Pill } from '$styles/pill'; interface CatalogCardProps { dataset: DatasetData; @@ -24,50 +33,52 @@ interface CatalogCardProps { linkProperties: LinkProperties; } -const CardSelectable = styled(Card)<{ checked?: boolean, selectable?: boolean }>` +const CardSelectable = styled(Card)<{ + checked?: boolean; + selectable?: boolean; +}>` outline: 4px solid transparent; ${({ checked }) => checked && css` - outline-color: ${themeVal("color.primary")}; + outline-color: ${themeVal('color.primary')}; `} ${({ selectable }) => selectable && css` - &::before { - content: ''; - position: absolute; - top: 50%; - left: 80px; - height: 3rem; - min-width: 3rem; - transform: translate(-50%, -50%); - padding: ${glsp(0.5, 1, 0.5, 1)}; - display: flex; - align-items: center; - justify-content: center; - background: ${themeVal("color.primary")}; - border-radius: ${themeVal("shape.ellipsoid")}; - font-weight: ${themeVal("type.base.bold")}; - line-height: 1rem; - background-image: url(${({ theme }) => - iconDataURI(CollecticonPlus, { - color: theme.color?.surface, - size: "large" - })}); - background-repeat: no-repeat; - background-position: 0.75rem center; - pointer-events: none; - transition: all 0.16s ease-in-out; - opacity: 0; - } + &::before { + content: ''; + position: absolute; + top: 50%; + left: 80px; + height: 3rem; + min-width: 3rem; + transform: translate(-50%, -50%); + padding: ${glsp(0.5, 1, 0.5, 1)}; + display: flex; + align-items: center; + justify-content: center; + background: ${themeVal('color.primary')}; + border-radius: ${themeVal('shape.ellipsoid')}; + font-weight: ${themeVal('type.base.bold')}; + line-height: 1rem; + background-image: url(${({ theme }) => + iconDataURI(CollecticonPlus, { + color: theme.color?.surface, + size: 'large' + })}); + background-repeat: no-repeat; + background-position: 0.75rem center; + pointer-events: none; + transition: all 0.16s ease-in-out; + opacity: 0; + } - &:hover ::before { - opacity: 1; - } - ` - } + &:hover ::before { + opacity: 1; + } + `} ${({ checked }) => checked && @@ -76,32 +87,77 @@ const CardSelectable = styled(Card)<{ checked?: boolean, selectable?: boolean }> opacity: 1; z-index: 10; content: 'Selected'; - color: ${themeVal("color.surface")}; + color: ${themeVal('color.surface')}; padding-left: 2.75rem; background-image: url(${({ theme }) => iconDataURI(CollecticonTickSmall, { color: theme.color?.surface, - size: "large" + size: 'large' })}); - background-color: ${themeVal("color.base")}; + background-color: ${themeVal('color.base')}; } &:hover ::before { - background-color: ${themeVal("color.primary")}; + background-color: ${themeVal('color.primary')}; } `} `; +/** + * Returns the description for a dataset or layer, prioritizing cardDescription prop set in the .mdx + */ +const getDescription = ( + layer: DatasetLayer | undefined, + dataset: DatasetData +): string => { + if (!layer) { + return dataset.cardDescription ?? dataset.description; + } + return layer.cardDescription ?? layer.description; +}; + +/** + * Retrieves media properties (src or alt) from either layer or dataset objects + * Follows a specific precedence order to find the first available value: + * 1. Layer card media property + * 2. Layer media property + * 3. Dataset card media property + * 4. Dataset media property + */ +const getMediaProperty = ( + layer: DatasetLayer | undefined, + dataset: DatasetData, + property: T +): string => { + const sources = [ + layer?.cardMedia?.[property], + layer?.media?.[property], + dataset.cardMedia?.[property], + dataset.media?.[property] + ]; + + return sources.find(Boolean) ?? ''; +}; + export const CatalogCard = (props: CatalogCardProps) => { - const { dataset, layer, searchTerm, selectable, selected, onDatasetClick, linkProperties, pathname} = props; + const { + dataset, + layer, + searchTerm, + selectable, + selected, + onDatasetClick, + linkProperties, + pathname + } = props; const topics = getTaxonomy(dataset, TAXONOMY_TOPICS)?.values; const sources = getTaxonomy(dataset, TAXONOMY_SOURCE)?.values; const allTaxonomyValues = getAllTaxonomyValues(dataset).map((v) => v.name); const title = layer ? layer.name : dataset.name; - const description = layer ? layer.description : dataset.description; - const imgSrc = layer?.media?.src ?? dataset.media?.src; - const imgAlt = layer?.media?.alt ?? dataset.media?.alt; + const description = getDescription(layer, dataset); + const imgSrc = getMediaProperty(layer, dataset, 'src'); + const imgAlt = getMediaProperty(layer, dataset, 'alt'); const handleClick = (e: React.MouseEvent) => { if (onDatasetClick) { @@ -145,7 +201,10 @@ export const CatalogCard = (props: CatalogCardProps) => { {topics.map((t) => (
- + {t.name} @@ -155,7 +214,11 @@ export const CatalogCard = (props: CatalogCardProps) => { ) : null} } - linkProperties={{...linkProperties, linkTo: linkTo, onLinkClick: handleClick}} + linkProperties={{ + ...linkProperties, + linkTo: linkTo, + onLinkClick: handleClick + }} /> ); }; diff --git a/app/scripts/components/common/featured-slider-section.tsx b/app/scripts/components/common/featured-slider-section.tsx index 824d1e2ce..fe967c6a6 100644 --- a/app/scripts/components/common/featured-slider-section.tsx +++ b/app/scripts/components/common/featured-slider-section.tsx @@ -70,12 +70,15 @@ function FeaturedSliderSection(props: FeaturedSliderSectionProps) { // Disable no-mutating rule since the copy of the array is being mutated // eslint-disable-next-line fp/no-mutating-methods - const sortedFeaturedItems = dateProperty? [...featuredItems].sort((itemA: StoryData | DatasetData, itemB: StoryData | DatasetData) => { - const pubDateOfItemA = new Date(itemA[dateProperty]); - const pubDateOfItemB = new Date(itemB[dateProperty]); - return pubDateOfItemB.getTime() - pubDateOfItemA.getTime(); - }) as StoryData[] | DatasetData[]: featuredItems; - + const sortedFeaturedItems = dateProperty + ? ([...featuredItems].sort( + (itemA: StoryData | DatasetData, itemB: StoryData | DatasetData) => { + const pubDateOfItemA = new Date(itemA[dateProperty]); + const pubDateOfItemB = new Date(itemB[dateProperty]); + return pubDateOfItemB.getTime() - pubDateOfItemA.getTime(); + } + ) as StoryData[] | DatasetData[]) + : featuredItems; return ( @@ -94,6 +97,16 @@ function FeaturedSliderSection(props: FeaturedSliderSectionProps) { const date = new Date(d[dateProperty ?? '']); const topics = getTaxonomy(d, TAXONOMY_TOPICS)?.values; + const cardDescription = + 'cardDescription' in d + ? (d as StoryData).cardDescription ?? d.description + : d.description; + + const cardMedia = + 'cardMedia' in d + ? (d as StoryData).cardMedia ?? d.media + : d.media; + return ( } - description={d.description} - imgSrc={d.media?.src} - imgAlt={d.media?.alt} + description={cardDescription} + imgSrc={cardMedia?.src} + imgAlt={cardMedia?.alt} footerContent={ <> {topics?.length ? ( diff --git a/app/scripts/components/common/related-content.tsx b/app/scripts/components/common/related-content.tsx index 79fcfbc99..2e3f90c76 100644 --- a/app/scripts/components/common/related-content.tsx +++ b/app/scripts/components/common/related-content.tsx @@ -49,11 +49,13 @@ interface FormatBlock { id: string; name: string; description: string; + cardDescription?: string; date: string; link: string; asLink?: LinkContentData; parentLink: string; media: Media; + cardMedia?: Media; parent: RelatedContentData['type']; } @@ -78,8 +80,10 @@ function formatBlock({ id, name, description, + cardDescription, date, media, + cardMedia, asLink, type }): FormatBlock { @@ -87,8 +91,10 @@ function formatBlock({ id, name, description, + cardDescription, date, media, + cardMedia, asLink, ...formatUrl(id, type), parent: type @@ -110,14 +116,17 @@ function formatContents(relatedData: RelatedContentData[]) { ); } - const { name, description, media } = matchingContent; + const { name, description, media, cardDescription, cardMedia } = + matchingContent; return formatBlock({ id, name, description, + cardDescription, asLink: (matchingContent as StoryData).asLink, date: (matchingContent as StoryData).pubDate, media, + cardMedia, type }); }); @@ -159,11 +168,11 @@ export default function RelatedContent(props: RelatedContentProps) { ? utcString2userTzDate(t.date) : undefined } - description={t.description} + description={t.cardDescription ?? t.description} tagLabels={[t.parent]} parentTo={t.parentLink} - imgSrc={t.media.src} - imgAlt={t.media.alt} + imgSrc={t.cardMedia?.src ?? t.media.src} + imgAlt={t.cardMedia?.alt ?? t.media.alt} /> ))} diff --git a/app/scripts/components/home/featured-stories.tsx b/app/scripts/components/home/featured-stories.tsx index a8bb557a7..bedca1746 100644 --- a/app/scripts/components/home/featured-stories.tsx +++ b/app/scripts/components/home/featured-stories.tsx @@ -87,10 +87,12 @@ function FeaturedStories() { title={d.name} tagLabels={[getString('stories').one]} parentTo={STORIES_PATH} - description={i === 0 ? d.description : undefined} + description={ + i === 0 ? d.cardDescription ?? d.description : undefined + } date={d.pubDate ? new Date(d.pubDate) : undefined} - imgSrc={d.media?.src} - imgAlt={d.media?.alt} + imgSrc={d.cardMedia?.src ?? d.media?.src} + imgAlt={d.cardMedia?.alt ?? d.media?.alt} /> ); diff --git a/app/scripts/components/stories/hub/hub-content.tsx b/app/scripts/components/stories/hub/hub-content.tsx index ae2c3ee5a..1ec98ef7e 100644 --- a/app/scripts/components/stories/hub/hub-content.tsx +++ b/app/scripts/components/stories/hub/hub-content.tsx @@ -17,7 +17,11 @@ import { } from '$components/common/fold'; import { useSlidingStickyHeaderProps } from '$components/common/layout-root/useSlidingStickyHeaderProps'; import { Card, LinkProperties } from '$components/common/card'; -import { CardListGrid, CardMeta, CardTopicsList } from '$components/common/card/styles'; +import { + CardListGrid, + CardMeta, + CardTopicsList +} from '$components/common/card/styles'; import EmptyHub from '$components/common/empty-hub'; import { prepareDatasets } from '$components/common/catalog/prepare-datasets'; @@ -54,17 +58,25 @@ const FoldWithTopMargin = styled(Fold)` margin-top: ${glsp()}; `; -interface StoryDataWithPath extends StoryData {path: string} +interface StoryDataWithPath extends StoryData { + path: string; +} interface HubContentProps { allStories: StoryDataWithPath[]; linkProperties: LinkProperties; pathname: string; - storiesString: {one: string, other: string}; + storiesString: { one: string; other: string }; onFilterchanges: () => UseFiltersWithQueryResult; } -export default function HubContent(props:HubContentProps) { - const { allStories, linkProperties, pathname, storiesString, onFilterchanges } = props; +export default function HubContent(props: HubContentProps) { + const { + allStories, + linkProperties, + pathname, + storiesString, + onFilterchanges + } = props; const browseControlsHeaderRef = useRef(null); const { headerHeight } = useSlidingStickyHeaderProps(); const { search, taxonomies, onAction } = onFilterchanges(); @@ -77,10 +89,12 @@ export default function HubContent(props:HubContentProps) { [pathAttributeKeyName]: pathname }; - function getPillLinkProps(t){ + function getPillLinkProps(t) { return { as: LinkElement, - [pathAttributeKeyName]: `${pathname}?${FilterActions.TAXONOMY}=${encodeURIComponent(JSON.stringify({ Topics: t.id }))}` + [pathAttributeKeyName]: `${pathname}?${ + FilterActions.TAXONOMY + }=${encodeURIComponent(JSON.stringify({ Topics: t.id }))}` }; } const displayStories = useMemo( @@ -89,142 +103,138 @@ export default function HubContent(props:HubContentProps) { search, taxonomies, sortField: 'pubDate', - sortDir: 'desc', + sortDir: 'desc' }) as StoryDataWithPath[], [search, taxonomies, allStories] ); const isFiltering = !!( - (taxonomies && Object.keys(taxonomies).length )|| + (taxonomies && Object.keys(taxonomies).length) || search ); - return ( - - - Browse - - - - - - - Showing{' '} - {' '} - out of {allStories.length}. - - {isFiltering && ( - - )} - - - {displayStories.length ? ( - - {displayStories.map((d) => { - const pubDate = new Date(d.pubDate); - const topics = getTaxonomy(d, TAXONOMY_TOPICS)?.values; - return ( -
  • - - { - onAction(FilterActions.TAXONOMY_MULTISELECT, { - key: TAXONOMY_SOURCE, - value: id - }); - browseControlsHeaderRef.current?.scrollIntoView(); - }} - /> - - - {!isNaN(pubDate.getTime()) && ( + return ( + + + + Browse + + + + + + + Showing{' '} + {' '} + out of {allStories.length}. + + {isFiltering && ( + + )} + + + {displayStories.length ? ( + + {displayStories.map((d) => { + const pubDate = new Date(d.pubDate); + const topics = getTaxonomy(d, TAXONOMY_TOPICS)?.values; + return ( +
  • + + { + onAction(FilterActions.TAXONOMY_MULTISELECT, { + key: TAXONOMY_SOURCE, + value: id + }); + browseControlsHeaderRef.current?.scrollIntoView(); + }} + /> + + + {!isNaN(pubDate.getTime()) && ( - )} - - } - linkLabel='View more' - linkProperties={{ - linkTo: `${d.asLink?.url ?? d.path}`, - LinkElement, - pathAttributeKeyName - }} - title={ - - {d.name} - - } - description={ - - {d.description} - - } - hideExternalLinkBadge={d.hideExternalLinkBadge} - imgSrc={d.media?.src} - imgAlt={d.media?.alt} - footerContent={ - <> - {topics?.length ? ( - -
    Topics
    - {topics.map((t) => ( -
    - { - browseControlsHeaderRef.current?.scrollIntoView(); - }} - > - + } + linkLabel='View more' + linkProperties={{ + linkTo: `${d.asLink?.url ?? d.path}`, + LinkElement, + pathAttributeKeyName + }} + title={ + + {d.name} + + } + description={ + + {d.cardDescription ?? d.description} + + } + hideExternalLinkBadge={d.hideExternalLinkBadge} + imgSrc={d.cardMedia?.src ?? d.media?.src} + imgAlt={d.cardMedia?.alt ?? d.media?.alt} + footerContent={ + <> + {topics?.length ? ( + +
    Topics
    + {topics.map((t) => ( +
    + { + browseControlsHeaderRef.current?.scrollIntoView(); + }} > - {t.name} - - -
    - ))} -
    - ) : null} - - } - /> -
  • - ); - })} -
    - ) : ( - - There are no {storiesString.other.toLocaleLowerCase()} to - show with the selected filters. - - )} -
    ); -} \ No newline at end of file + + {t.name} + + +
    + ))} + + ) : null} + + } + /> + + ); + })} + + ) : ( + + There are no {storiesString.other.toLocaleLowerCase()} to show with + the selected filters. + + )} + + ); +} diff --git a/app/scripts/types/veda.ts b/app/scripts/types/veda.ts index ee2873d31..ec3d1f663 100644 --- a/app/scripts/types/veda.ts +++ b/app/scripts/types/veda.ts @@ -63,10 +63,12 @@ export interface DatasetLayer extends DatasetLayerCommonProps { id: string; stacCol: string; media?: Media; + cardMedia?: Media; stacApiEndpoint?: string; tileApiEndpoint?: string; name: string; description: string; + cardDescription?: string; initialDatetime?: 'newest' | 'oldest' | string; projection?: ProjectionOptions; basemapId?: 'dark' | 'light' | 'satellite' | 'topo'; @@ -180,8 +182,10 @@ export interface DatasetData { infoDescription?: string; taxonomy: Taxonomy[]; description: string; + cardDescription?: string; usage?: DatasetUsage[]; media?: Media; + cardMedia?: Media; layers: DatasetLayer[]; related?: RelatedContentData[]; disableExplore?: boolean; @@ -200,9 +204,11 @@ export interface StoryData { id: string; name: string; description: string; + cardDescription?: string; pubDate: string; path?: string; media?: Media; + cardMedia?: Media; taxonomy: Taxonomy[]; related?: RelatedContentData[]; asLink?: LinkContentData; diff --git a/parcel-resolver-veda/index.d.ts b/parcel-resolver-veda/index.d.ts index 5b558b4ca..0ca467bff 100644 --- a/parcel-resolver-veda/index.d.ts +++ b/parcel-resolver-veda/index.d.ts @@ -182,8 +182,10 @@ declare module 'veda' { infoDescription?: string; taxonomy: Taxonomy[]; description: string; + cardDescription?: string; usage?: DatasetUsage[]; media?: Media; + cardMedia?: Media; layers: DatasetLayer[]; related?: RelatedContentData[]; disableExplore?: boolean; @@ -202,8 +204,10 @@ declare module 'veda' { id: string; name: string; description: string; + cardDescription?: string; pubDate: string; media?: Media; + cardMedia?: Media; taxonomy: Taxonomy[]; related?: RelatedContentData[]; asLink?: LinkContentData; From 842216db03c105e148f958233dab3dec11d265c4 Mon Sep 17 00:00:00 2001 From: Gjore Milevski Date: Fri, 8 Nov 2024 09:43:59 +0100 Subject: [PATCH 2/2] Re-factor utils, add unit tests --- .../common/card/horizontal-info-card.tsx | 9 +- .../common/catalog/catalog-card.tsx | 39 +-- .../components/common/catalog/utils.test.ts | 265 +++++++++++++++++- .../components/common/catalog/utils.ts | 58 +++- .../common/featured-slider-section.tsx | 17 +- .../components/common/related-content.tsx | 31 +- app/scripts/components/common/types.d.ts | 13 + .../components/home/featured-stories.tsx | 12 +- .../components/stories/hub/hub-content.tsx | 12 +- 9 files changed, 366 insertions(+), 90 deletions(-) create mode 100644 app/scripts/components/common/types.d.ts diff --git a/app/scripts/components/common/card/horizontal-info-card.tsx b/app/scripts/components/common/card/horizontal-info-card.tsx index c4f748c1d..1ad714745 100644 --- a/app/scripts/components/common/card/horizontal-info-card.tsx +++ b/app/scripts/components/common/card/horizontal-info-card.tsx @@ -81,14 +81,15 @@ export default function HorizontalInfoCard(props: Props) {

    {description}

    -
    - {tagLabels && - tagLabels.map((label) => ( + {tagLabels && tagLabels.length > 0 && ( +
    + {tagLabels.map((label) => ( {label} ))} -
    +
    + )} ); diff --git a/app/scripts/components/common/catalog/catalog-card.tsx b/app/scripts/components/common/catalog/catalog-card.tsx index a941191cb..e9a85a751 100644 --- a/app/scripts/components/common/catalog/catalog-card.tsx +++ b/app/scripts/components/common/catalog/catalog-card.tsx @@ -12,6 +12,7 @@ import { CardMeta, CardTopicsList } from '../card/styles'; import { DatasetClassification } from '../dataset-classification'; import { CardSourcesList } from '../card-sources'; import TextHighlight from '../text-highlight'; +import { getDatasetDescription, getMediaProperty } from './utils'; import { DatasetData, DatasetLayer } from '$types/veda'; import { getDatasetPath } from '$utils/routes'; import { @@ -102,42 +103,6 @@ const CardSelectable = styled(Card)<{ `} `; -/** - * Returns the description for a dataset or layer, prioritizing cardDescription prop set in the .mdx - */ -const getDescription = ( - layer: DatasetLayer | undefined, - dataset: DatasetData -): string => { - if (!layer) { - return dataset.cardDescription ?? dataset.description; - } - return layer.cardDescription ?? layer.description; -}; - -/** - * Retrieves media properties (src or alt) from either layer or dataset objects - * Follows a specific precedence order to find the first available value: - * 1. Layer card media property - * 2. Layer media property - * 3. Dataset card media property - * 4. Dataset media property - */ -const getMediaProperty = ( - layer: DatasetLayer | undefined, - dataset: DatasetData, - property: T -): string => { - const sources = [ - layer?.cardMedia?.[property], - layer?.media?.[property], - dataset.cardMedia?.[property], - dataset.media?.[property] - ]; - - return sources.find(Boolean) ?? ''; -}; - export const CatalogCard = (props: CatalogCardProps) => { const { dataset, @@ -155,7 +120,7 @@ export const CatalogCard = (props: CatalogCardProps) => { const allTaxonomyValues = getAllTaxonomyValues(dataset).map((v) => v.name); const title = layer ? layer.name : dataset.name; - const description = getDescription(layer, dataset); + const description = getDatasetDescription(layer, dataset); const imgSrc = getMediaProperty(layer, dataset, 'src'); const imgAlt = getMediaProperty(layer, dataset, 'alt'); diff --git a/app/scripts/components/common/catalog/utils.test.ts b/app/scripts/components/common/catalog/utils.test.ts index 60e8428b0..837cefcfe 100644 --- a/app/scripts/components/common/catalog/utils.test.ts +++ b/app/scripts/components/common/catalog/utils.test.ts @@ -1,5 +1,13 @@ import { omit, set } from 'lodash'; -import { FilterActions, onFilterAction } from './utils'; +import { FormatBlock } from '../types'; +import { + FilterActions, + getDatasetDescription, + getDescription, + getMediaProperty, + onFilterAction +} from './utils'; +import { DatasetData, DatasetLayer, StoryData } from '$types/veda'; describe('onFilterAction', () => { let setSearchMock; @@ -142,3 +150,258 @@ describe('onFilterAction', () => { ); }); }); + +describe('getDescription', () => { + it('should return cardDescription when available', () => { + const data = { + description: 'Regular description', + cardDescription: 'Card description' + } as DatasetData; + + expect(getDescription(data)).toBe('Card description'); + }); + + it('should fall back to description when cardDescription is not available', () => { + const data = { + description: 'Regular description' + } as DatasetData; + + expect(getDescription(data)).toBe('Regular description'); + }); + + it('works with different data types', () => { + const dataset = { + description: 'Dataset description', + cardDescription: 'Dataset card description' + } as DatasetData; + + const story = { + description: 'Story description', + cardDescription: 'Story card description' + } as StoryData; + + const layer = { + description: 'Layer description', + cardDescription: 'Layer card description' + } as DatasetLayer; + + expect(getDescription(dataset)).toBe('Dataset card description'); + expect(getDescription(story)).toBe('Story card description'); + expect(getDescription(layer)).toBe('Layer card description'); + }); + + it('works with FormatBlock', () => { + const formatBlock = { + id: '1', + name: 'Test', + description: 'Format block description', + cardDescription: 'Format block card description', + date: '2024-01-01', + link: '/test', + parentLink: '/parent', + media: { src: 'src', alt: 'alt' }, + parent: 'story' + } as FormatBlock; + + expect(getDescription(formatBlock)).toBe('Format block card description'); + }); + + it('works with FormatBlock without cardDescription', () => { + const formatBlock = { + id: '1', + name: 'Test', + description: 'Format block description', + date: '2024-01-01', + link: '/test', + parentLink: '/parent', + media: { src: 'src', alt: 'alt' }, + parent: 'story' + } as FormatBlock; + + expect(getDescription(formatBlock)).toBe('Format block description'); + }); + + it('handles empty strings in description fields', () => { + const data = { + description: '', + cardDescription: '' + } as DatasetData; + + expect(getDescription(data)).toBe(''); + }); + + it('handles undefined cardDescription', () => { + const data = { + description: 'Regular description', + cardDescription: undefined + } as DatasetData; + + expect(getDescription(data)).toBe('Regular description'); + }); +}); + +describe('getDatasetDescription', () => { + const dataset = { + description: 'Dataset description', + cardDescription: 'Dataset card description' + } as DatasetData; + + const layer = { + description: 'Layer description', + cardDescription: 'Layer card description' + } as DatasetLayer; + + it('should return layer description when layer is provided', () => { + expect(getDatasetDescription(layer, dataset)).toBe( + 'Layer card description' + ); + }); + + it('should return dataset description when no layer is provided', () => { + expect(getDatasetDescription(undefined, dataset)).toBe( + 'Dataset card description' + ); + }); + + it('should handle layer without cardDescription', () => { + const layerWithoutCard = { + description: 'Layer description' + } as DatasetLayer; + + expect(getDatasetDescription(layerWithoutCard, dataset)).toBe( + 'Layer description' + ); + }); + + it('should work with story data', () => { + const story = { + description: 'Story description', + cardDescription: 'Story card description' + } as StoryData; + + expect(getDatasetDescription(undefined, story)).toBe( + 'Story card description' + ); + }); +}); + +describe('getMediaProperty', () => { + const dataset = { + media: { + src: 'dataset-src', + alt: 'dataset-alt' + }, + cardMedia: { + src: 'dataset-card-src', + alt: 'dataset-card-alt' + } + } as DatasetData; + + const layer = { + media: { + src: 'layer-src', + alt: 'layer-alt' + }, + cardMedia: { + src: 'layer-card-src', + alt: 'layer-card-alt' + } + } as DatasetLayer; + + it('should follow precedence order for src property', () => { + // Test full precedence chain + expect(getMediaProperty(layer, dataset, 'src')).toBe('layer-card-src'); + + // Test without layer cardMedia + const layerNoCard = { ...layer, cardMedia: undefined }; + expect(getMediaProperty(layerNoCard, dataset, 'src')).toBe('layer-src'); + + // Test without layer + expect(getMediaProperty(undefined, dataset, 'src')).toBe( + 'dataset-card-src' + ); + + // Test with minimal data + const minimalDataset = { + media: { src: 'only-src', alt: 'only-alt' } + } as DatasetData; + expect(getMediaProperty(undefined, minimalDataset, 'src')).toBe('only-src'); + }); + + it('should follow precedence order for alt property', () => { + expect(getMediaProperty(layer, dataset, 'alt')).toBe('layer-card-alt'); + + const layerNoCard = { ...layer, cardMedia: undefined }; + expect(getMediaProperty(layerNoCard, dataset, 'alt')).toBe('layer-alt'); + + expect(getMediaProperty(undefined, dataset, 'alt')).toBe( + 'dataset-card-alt' + ); + }); + + it('should work with FormatBlock', () => { + const formatBlock = { + id: '1', + name: 'Test', + description: 'description', + date: '2024-01-01', + link: '/test', + parentLink: '/parent', + media: { src: 'media-src', alt: 'media-alt' }, + cardMedia: { src: 'card-src', alt: 'card-alt' }, + parent: 'story' + } as FormatBlock; + + expect(getMediaProperty(undefined, formatBlock, 'src')).toBe('card-src'); + expect(getMediaProperty(undefined, formatBlock, 'alt')).toBe('card-alt'); + }); + + it('should fall back to media in FormatBlock when cardMedia is not available', () => { + const formatBlock = { + id: '1', + name: 'Test', + description: 'description', + date: '2024-01-01', + link: '/test', + parentLink: '/parent', + media: { src: 'media-src', alt: 'media-alt' }, + parent: 'story' + } as FormatBlock; + + expect(getMediaProperty(undefined, formatBlock, 'src')).toBe('media-src'); + expect(getMediaProperty(undefined, formatBlock, 'alt')).toBe('media-alt'); + }); + + it('should work with layer and FormatBlock combination', () => { + const formatBlock = { + id: '1', + name: 'Test', + description: 'description', + date: '2024-01-01', + link: '/test', + parentLink: '/parent', + media: { src: 'media-src', alt: 'media-alt' }, + cardMedia: { src: 'card-src', alt: 'card-alt' }, + parent: 'story' + } as FormatBlock; + + expect(getMediaProperty(layer, formatBlock, 'src')).toBe('layer-card-src'); + expect(getMediaProperty(layer, formatBlock, 'alt')).toBe('layer-card-alt'); + }); + + it('should return empty string when no media is available', () => { + const emptyDataset = {} as DatasetData; + expect(getMediaProperty(undefined, emptyDataset, 'src')).toBe(''); + expect(getMediaProperty(undefined, emptyDataset, 'alt')).toBe(''); + }); + + it('should work with story data', () => { + const story = { + media: { src: 'story-src', alt: 'story-alt' }, + cardMedia: { src: 'story-card-src', alt: 'story-card-alt' } + } as StoryData; + + expect(getMediaProperty(undefined, story, 'src')).toBe('story-card-src'); + expect(getMediaProperty(undefined, story, 'alt')).toBe('story-card-alt'); + }); +}); diff --git a/app/scripts/components/common/catalog/utils.ts b/app/scripts/components/common/catalog/utils.ts index ded96f3b6..2fb9ad1a4 100644 --- a/app/scripts/components/common/catalog/utils.ts +++ b/app/scripts/components/common/catalog/utils.ts @@ -1,5 +1,7 @@ import { omit, set } from 'lodash'; +import { FormatBlock } from '../types'; import { optionAll } from '$components/common/browse-controls/constants'; +import { DatasetData, DatasetLayer, StoryData } from '$types/veda'; export enum FilterActions { TAXONOMY_MULTISELECT = 'taxonomy_multiselect', @@ -9,7 +11,7 @@ export enum FilterActions { SORT_DIR = 'sdir', TAXONOMY = 'taxonomy', CLEAR_TAXONOMY = 'clear_taxonomy', - CLEAR_SEARCH = 'clear_search', + CLEAR_SEARCH = 'clear_search' } export type FilterAction = (action: FilterActions, value?: any) => void; @@ -77,3 +79,57 @@ export function onFilterAction( break; } } + +type DataObject = DatasetData | DatasetLayer | StoryData | FormatBlock; + +/** + * Returns the description for a dataset, layer, or story, prioritizing cardDescription + * @param data - Dataset, layer, or story object + * @returns The appropriate description string + */ +export const getDescription = (data: DataObject): string => { + return data.cardDescription ?? data.description; +}; + +/** + * Returns the description for a dataset/story and its optional layer + * Layer description takes precedence over dataset/story description + * @param layer - Optional layer object + * @param data - Dataset or story object + * @returns The appropriate description string + */ +export const getDatasetDescription = ( + layer: DatasetLayer | undefined, + data: DatasetData | StoryData +): string => { + if (!layer) { + return getDescription(data); + } + return getDescription(layer); +}; + +/** + * Gets media property following strict precedence order: + * 1. Layer cardMedia + * 2. Layer media + * 3. Parent cardMedia + * 4. Parent media + * + * @param layer - Optional layer object + * @param data - Parent (dataset/story) object + * @param property - Media property to retrieve ('src' or 'alt') + * @returns The appropriate media property value + */ +export const getMediaProperty = ( + layer: DatasetLayer | undefined, + data: DatasetData | StoryData | FormatBlock, + property: T +): string => { + return ( + layer?.cardMedia?.[property] ?? + layer?.media?.[property] ?? + data.cardMedia?.[property] ?? + data.media?.[property] ?? + '' + ); +}; diff --git a/app/scripts/components/common/featured-slider-section.tsx b/app/scripts/components/common/featured-slider-section.tsx index fe967c6a6..d4cc0a123 100644 --- a/app/scripts/components/common/featured-slider-section.tsx +++ b/app/scripts/components/common/featured-slider-section.tsx @@ -6,6 +6,7 @@ import SmartLink from './smart-link'; import PublishedDate from './pub-date'; import { CardSourcesList } from './card-sources'; import { DatasetClassification } from './dataset-classification'; +import { getDescription, getMediaProperty } from './catalog/utils'; import { Card } from '$components/common/card'; import { CardMeta, CardTopicsList } from '$components/common/card/styles'; import { FoldGrid, FoldHeader, FoldTitle } from '$components/common/fold'; @@ -97,16 +98,6 @@ function FeaturedSliderSection(props: FeaturedSliderSectionProps) { const date = new Date(d[dateProperty ?? '']); const topics = getTaxonomy(d, TAXONOMY_TOPICS)?.values; - const cardDescription = - 'cardDescription' in d - ? (d as StoryData).cardDescription ?? d.description - : d.description; - - const cardMedia = - 'cardMedia' in d - ? (d as StoryData).cardMedia ?? d.media - : d.media; - return ( } - description={cardDescription} - imgSrc={cardMedia?.src} - imgAlt={cardMedia?.alt} + description={getDescription(d)} + imgSrc={getMediaProperty(undefined, d, 'src')} + imgAlt={getMediaProperty(undefined, d, 'alt')} footerContent={ <> {topics?.length ? ( diff --git a/app/scripts/components/common/related-content.tsx b/app/scripts/components/common/related-content.tsx index 2e3f90c76..2ef23b869 100644 --- a/app/scripts/components/common/related-content.tsx +++ b/app/scripts/components/common/related-content.tsx @@ -2,15 +2,10 @@ import React from 'react'; import styled from 'styled-components'; import { media } from '@devseed-ui/theme-provider'; -import { - stories, - datasets, - Media, - RelatedContentData, - LinkContentData, - StoryData -} from 'veda'; +import { stories, datasets, RelatedContentData, StoryData } from 'veda'; import SmartLink from './smart-link'; +import { getDescription, getMediaProperty } from './catalog/utils'; +import { FormatBlock } from './types'; import { utcString2userTzDate } from '$utils/date'; import { getDatasetPath, @@ -45,20 +40,6 @@ const RelatedContentInner = styled.div` `} `; -interface FormatBlock { - id: string; - name: string; - description: string; - cardDescription?: string; - date: string; - link: string; - asLink?: LinkContentData; - parentLink: string; - media: Media; - cardMedia?: Media; - parent: RelatedContentData['type']; -} - function formatUrl(id: string, parent: string) { switch (parent) { case datasetString: @@ -168,11 +149,11 @@ export default function RelatedContent(props: RelatedContentProps) { ? utcString2userTzDate(t.date) : undefined } - description={t.cardDescription ?? t.description} + description={getDescription(t)} tagLabels={[t.parent]} parentTo={t.parentLink} - imgSrc={t.cardMedia?.src ?? t.media.src} - imgAlt={t.cardMedia?.alt ?? t.media.alt} + imgSrc={getMediaProperty(undefined, t, 'src')} + imgAlt={getMediaProperty(undefined, t, 'alt')} /> ))} diff --git a/app/scripts/components/common/types.d.ts b/app/scripts/components/common/types.d.ts new file mode 100644 index 000000000..ec06f7b92 --- /dev/null +++ b/app/scripts/components/common/types.d.ts @@ -0,0 +1,13 @@ +export interface FormatBlock { + id: string; + name: string; + description: string; + cardDescription?: string; + date: string; + link: string; + asLink?: LinkContentData; + parentLink: string; + media: Media; + cardMedia?: Media; + parent: RelatedContentData['type']; +} diff --git a/app/scripts/components/home/featured-stories.tsx b/app/scripts/components/home/featured-stories.tsx index bedca1746..e043cbe35 100644 --- a/app/scripts/components/home/featured-stories.tsx +++ b/app/scripts/components/home/featured-stories.tsx @@ -8,6 +8,10 @@ import { Fold, FoldHeader, FoldTitle, FoldBody } from '$components/common/fold'; import { variableGlsp } from '$styles/variable-utils'; import { STORIES_PATH, getStoryPath } from '$utils/routes'; import SmartLink from '$components/common/smart-link'; +import { + getDescription, + getMediaProperty +} from '$components/common/catalog/utils'; const FeaturedStoryList = styled.ol` ${listReset()} @@ -87,12 +91,10 @@ function FeaturedStories() { title={d.name} tagLabels={[getString('stories').one]} parentTo={STORIES_PATH} - description={ - i === 0 ? d.cardDescription ?? d.description : undefined - } + description={i === 0 ? getDescription(d) : undefined} date={d.pubDate ? new Date(d.pubDate) : undefined} - imgSrc={d.cardMedia?.src ?? d.media?.src} - imgAlt={d.cardMedia?.alt ?? d.media?.alt} + imgSrc={getMediaProperty(undefined, d, 'src')} + imgAlt={getMediaProperty(undefined, d, 'alt')} /> ); diff --git a/app/scripts/components/stories/hub/hub-content.tsx b/app/scripts/components/stories/hub/hub-content.tsx index 1ec98ef7e..a459c2e8d 100644 --- a/app/scripts/components/stories/hub/hub-content.tsx +++ b/app/scripts/components/stories/hub/hub-content.tsx @@ -8,7 +8,11 @@ import { VerticalDivider } from '@devseed-ui/toolbar'; import { Subtitle } from '@devseed-ui/typography'; import PublishedDate from '$components/common/pub-date'; import BrowseControls from '$components/common/browse-controls'; -import { FilterActions } from '$components/common/catalog/utils'; +import { + FilterActions, + getDescription, + getMediaProperty +} from '$components/common/catalog/utils'; import { Fold, FoldHeader, @@ -192,12 +196,12 @@ export default function HubContent(props: HubContentProps) { } description={ - {d.cardDescription ?? d.description} + {getDescription(d)} } hideExternalLinkBadge={d.hideExternalLinkBadge} - imgSrc={d.cardMedia?.src ?? d.media?.src} - imgAlt={d.cardMedia?.alt ?? d.media?.alt} + imgSrc={getMediaProperty(undefined, d, 'src')} + imgAlt={getMediaProperty(undefined, d, 'alt')} footerContent={ <> {topics?.length ? (