Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ TwirlKeys.templateImports ++= Seq(
routesImport += "model.editions._"

val awsVersion = "1.12.470"
val capiModelsVersion = "38.0.0"
val capiClientVersion = "42.0.1"
val capiModelsVersion =
"45.0.0-PREVIEW.add-football-competition-atom.2026-07-15T1406.97e79cef"
val capiClientVersion =
"45.0.0-PREVIEW.add-football-competition-atom.2026-07-16T1458.7529b092"
val json4sVersion = "4.0.3"
val circeVersion = "0.13.0"
val awsSdkVersion = "2.43.0"
Expand Down Expand Up @@ -84,7 +86,7 @@ libraryDependencies ++= Seq(
"com.gu" %% "content-api-client-aws" % "0.7.6",
"com.gu" %% "content-api-client-default" % capiClientVersion,
"com.gu" %% "editorial-permissions-client" % "3.0.0",
"com.gu" %% "fapi-client-play30" % "30.0.0",
"com.gu" %% "fapi-client-play30" % "34.0.0-PREVIEW.gluse-capi-football-comp-preview-release.2026-07-16T1521.06be11fa",
"com.gu" %% "mobile-notifications-api-models" % "4.0.0",
"com.gu" %% "pan-domain-auth-play_3-0" % "7.0.0",
"org.scanamo" %% "scanamo" % "1.1.1" exclude ("org.scala-lang.modules", "scala-java8-compat_2.13"),
Expand Down
96 changes: 77 additions & 19 deletions fronts-client/src/bundles/capiFeedBundle.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createIndexedAsyncResourceBundle } from 'lib/createAsyncResourceBundle';
import { CapiArticle } from 'types/Capi';
import { CapiArticle, CapiAtom, isCapiAtom } from 'types/Capi';
import { ThunkResult } from 'types/Store';
import { previewCapi, liveCapi } from 'services/capiQuery';
import { checkIsContent } from 'services/capiQuery';
Expand All @@ -8,7 +8,11 @@ import { Dispatch } from 'redux';
import type { State } from 'types/State';
import { createSelectIsArticleStale } from 'util/externalArticle';

type FeedState = CapiArticle;
type FeedState = CapiArticle | CapiAtom;

export type FeedEntry =
| { type: 'article'; id: string }
| { type: 'atom'; id: string };

const {
actions: liveActions,
Expand All @@ -18,11 +22,17 @@ const {
selectLocalState: (state) => state.feed.capiLiveFeed,
});

const isNonCommercialArticle = (article: CapiArticle | undefined): boolean => {
const isNonCommercialArticle = (
article: CapiArticle | CapiAtom | undefined,
): boolean => {
if (!article) {
return true;
}

if (isCapiAtom(article)) {
return true;
}

if (article.isHosted) {
return false;
}
Expand All @@ -44,17 +54,34 @@ const {

const fetchResourceOrResults = async (
capiService: typeof liveCapi,
params: object,
params: object & { includeAtoms?: boolean },
isResource: boolean,
fetchFromPreview: boolean = false,
) => {
const { includeAtoms, ...capiParams } = params as any;
const capiEndpoint = fetchFromPreview
? capiService.scheduled
: capiService.search;
const { response } = await capiEndpoint(params, { isResource });

const mainRequest = capiEndpoint(capiParams, { isResource });
const atomsRequest =
includeAtoms && !isResource
? capiService.atoms({ q: capiParams.q })
: Promise.resolve(null);

const [{ response }, atomsResponse] = await Promise.all([
mainRequest,
atomsRequest,
]);

const atomResults: CapiAtom[] = atomsResponse
? atomsResponse.response.results
: [];

return {
results: checkIsContent(response) ? [response.content] : response.results,
results: checkIsContent(response)
? [response.content]
: [...response.results, ...atomResults],
pagination: checkIsContent(response)
? undefined
: {
Expand Down Expand Up @@ -85,17 +112,17 @@ export const createFetch =
const nonCommercialResults = resultData.results.filter((article) =>
isNonCommercialArticle(article),
);
const updatedResults = nonCommercialResults.filter((article) =>
selectIsArticleStale(
getState(),
article.id,
article.fields.lastModified,
),
);
const updatedResults = nonCommercialResults.filter((article) => {
const lastModified = isCapiAtom(article)
? article.contentChangeDetails.lastModified?.date
: article.fields.lastModified;
return selectIsArticleStale(getState(), article.id, lastModified);
});

dispatch(
actions.fetchSuccess(updatedResults, {
pagination: resultData.pagination || undefined,
order: nonCommercialResults.map((_) => _.id),
order: nonCommercialResults.map((item) => item.id),
}),
);
} else {
Expand Down Expand Up @@ -136,14 +163,16 @@ export const fetchPrefill =
try {
const { response } = await getPrefills(id);
if (!checkIsContent(response)) {
const filteredResults = response.results.filter(isNonCommercialArticle);
dispatch(
prefillActions.fetchSuccess(
response.results.filter(isNonCommercialArticle, {
prefillActions.fetchSuccess(filteredResults, {
order: filteredResults.map((item) => item.id),
pagination: {
totalPages: response.pages,
currentPage: response.currentPage,
pageSize: response.pageSize,
}),
),
},
}),
);
}
} catch (e) {
Expand All @@ -161,11 +190,40 @@ export const hidePrefills = () => (dispatch: Dispatch) => {
export const selectArticleAcrossResources = (
state: State,
id: string,
): CapiArticle | undefined =>
): CapiArticle | CapiAtom | undefined =>
liveSelectors.selectById(state, id) ||
previewSelectors.selectById(state, id) ||
prefillSelectors.selectById(state, id);

/** Derive typed FeedEntry[] from a bundle's ID list by looking up each item's type. */
const selectFeedEntries =
(
selectById: (state: State, id: string) => FeedState | undefined,
selectIds: (state: State) => string[],
) =>
(state: State): FeedEntry[] =>
selectIds(state).map((id) => {
const item = selectById(state, id);
return {
type:
item && isCapiAtom(item) ? ('atom' as const) : ('article' as const),
id,
};
});

export const selectLiveFeedEntries = selectFeedEntries(
liveSelectors.selectById,
liveSelectors.selectLastFetchOrder,
);
export const selectPreviewFeedEntries = selectFeedEntries(
previewSelectors.selectById,
previewSelectors.selectLastFetchOrder,
);
export const selectPrefillFeedEntries = selectFeedEntries(
prefillSelectors.selectById,
prefillSelectors.selectLastFetchOrder,
);

export {
liveActions,
previewActions,
Expand Down
21 changes: 20 additions & 1 deletion fronts-client/src/components/card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { ChefCard } from 'components/card/chef/ChefCard';
import { ChefMetaForm } from '../form/ChefMetaForm';
import { FeastCollectionCard } from './feastCollection/FeastCollectionCard';
import { FeastCollectionMetaForm } from 'components/form/FeastCollectionMetaForm';
import { InteractiveAtomCard } from './interactiveAtom/InteractiveAtomCard';
import { selectCollectionType } from 'selectors/frontsSelectors';
import { Criteria } from 'types/Grid';
import { Card as CardType } from 'types/Collection';
Expand Down Expand Up @@ -304,6 +305,23 @@ class Card extends React.Component<CardContainerProps> {
: this.state.showCardSublinks && children}
</>
);
case CardTypesMap.INTERACTIVE_ATOM:
return (
<>
<InteractiveAtomCard
frontId={frontId}
collectionId={collectionId}
id={uuid}
isUneditable={isUneditable}
{...getNodeProps()}
onDelete={this.onDelete}
onAddToClipboard={this.handleAddToClipboard}
size={size}
textSize={textSize}
showMeta={showMeta}
/>
</>
);
default:
return (
<p>
Expand Down Expand Up @@ -363,7 +381,8 @@ class Card extends React.Component<CardContainerProps> {
}
};

const supportsForm = type !== 'recipe';
const supportsForm =
type !== 'recipe' && type !== CardTypesMap.INTERACTIVE_ATOM;
const shouldDisplayForm = isSelected && supportsForm;

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React from 'react';
import { Card, CardSizes } from 'types/Collection';
import CardContainer from '../CardContainer';
import CardContent from '../CardContent';
import CardHeadingContainer from '../CardHeadingContainer';
import CardMetaHeading from '../CardMetaHeading';
import CardHeading from '../CardHeading';
import { selectCard } from 'selectors/shared';
import { State } from 'types/State';
import CardBody from '../CardBody';
import CardMetaContainer from '../CardMetaContainer';
import { useSelector } from 'react-redux';
import { HoverActionsAreaOverlay } from 'components/CollectionHoverItems';
import { HoverActionsButtonWrapper } from 'components/inputs/HoverActionButtonWrapper';
import {
HoverAddToClipboardButton,
HoverDeleteButton,
HoverViewButton,
} from 'components/inputs/HoverActionButtons';

interface Props {
onDragStart?: (d: React.DragEvent<HTMLElement>) => void;
onDrop?: (d: React.DragEvent<HTMLElement>) => void;
onDelete: () => void;
onAddToClipboard: () => void;
onClick?: () => void;
id: string;
collectionId?: string;
frontId: string;
draggable?: boolean;
size?: CardSizes;
textSize?: CardSizes;
fade?: boolean;
children?: React.ReactNode;
isUneditable?: boolean;
showMeta?: boolean;
}

export const InteractiveAtomCard = ({
id,
fade,
size = 'default',
textSize = 'default',
onDelete,
onAddToClipboard,
showMeta = true,
...rest
}: Props) => {
const card = useSelector<State, Card>((state) => selectCard(state, id));
const { headline, atomId, snapUri } = card.meta ?? {};
const atomUrl = atomId ? `https://www.theguardian.com/${atomId}` : snapUri;

return (
<CardContainer {...rest}>
<CardBody data-testid="interactive-atom" size={size} fade={fade}>
{showMeta && (
<CardMetaContainer size={size}>
<CardMetaHeading>Interactive Atom</CardMetaHeading>
</CardMetaContainer>
)}
<CardContent textSize={textSize}>
<CardHeadingContainer size={size}>
<CardHeading data-testid="headline">
{headline ?? atomId ?? 'Unknown interactive atom'}
</CardHeading>
</CardHeadingContainer>
</CardContent>
<HoverActionsAreaOverlay data-testid="hover-overlay">
<HoverActionsButtonWrapper
toolTipPosition={'top'}
toolTipAlign={'right'}
urlPath={atomUrl}
renderButtons={(props) => (
<>
<HoverViewButton hoverText="View" href={atomUrl} {...props} />
<HoverAddToClipboardButton
onAddToClipboard={onAddToClipboard}
hoverText="Clipboard"
{...props}
/>
<HoverDeleteButton
hoverText="Delete"
onDelete={onDelete}
{...props}
/>
</>
)}
/>
</HoverActionsAreaOverlay>
</CardBody>
</CardContainer>
);
};
12 changes: 8 additions & 4 deletions fronts-client/src/components/feed/ArticleFeedItem.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CapiArticle } from '../../types/Capi';
import { isCapiAtom } from '../../types/Capi';
import React from 'react';
import {
dragOffsetX,
Expand Down Expand Up @@ -93,10 +94,13 @@ const ArticleFeedItemComponent = ({
);
};

const mapStateToProps = (state: State, { id }: ContainerProps) => ({
shouldObscureFeed: selectFeatureValue(state, 'obscure-feed'),
article: selectArticleAcrossResources(state, id),
});
const mapStateToProps = (state: State, { id }: ContainerProps) => {
const resource = selectArticleAcrossResources(state, id);
return {
shouldObscureFeed: selectFeatureValue(state, 'obscure-feed'),
article: resource && !isCapiAtom(resource) ? resource : undefined,
};
};

const mapDispatchToProps = (dispatch: Dispatch) => {
return {
Expand Down
Loading
Loading