Skip to content

Commit

Permalink
Merge pull request #1666 from guardian/ag/convert-feast-collection
Browse files Browse the repository at this point in the history
Ability to convert a container to a Feast collection and vice-versa
  • Loading branch information
fredex42 authored Sep 26, 2024
2 parents b11a4f5 + d883147 commit aad3276
Show file tree
Hide file tree
Showing 11 changed files with 199 additions and 22 deletions.
66 changes: 63 additions & 3 deletions app/controllers/EditionsController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import model.editions._
import model.editions.templates.CuratedPlatformDefinition
import model.forms._
import net.logstash.logback.marker.Markers
import play.api.libs.json.{JsObject, Json, OFormat}
import play.api.libs.json.{JsObject, Json}
import play.api.mvc.Result
import services.Capi
import services.editions.EditionsTemplating
Expand All @@ -24,8 +24,11 @@ import util.{SearchResponseUtil, UserUtil}
import scala.jdk.CollectionConverters._
import scala.concurrent.ExecutionContext
import scala.util.Try
import model.editions.client.{EditionsClientCollection, EditionsFrontendCollectionWrapper}
import model.editions.client.EditionsFrontendCollectionWrapper
import play.api.libs.json.Format.GenericFormat
import scalikejdbc.DB

import java.util.UUID

class EditionsController(db: EditionsDB,
templating: EditionsTemplating,
Expand Down Expand Up @@ -315,7 +318,7 @@ class EditionsController(db: EditionsDB,
now = OffsetDateTime.now(),
name = req.queryString.get("name").flatMap(_.headOption)
) match {
case Right(front) =>
case Right((front, _)) =>
val collections = toClientCollections(front)
Ok(Json.toJson(collections))
case Left(EditionsDB.NotFoundError(message)) => NotFound(message)
Expand Down Expand Up @@ -351,4 +354,61 @@ class EditionsController(db: EditionsDB,
)
}

private def getFeastCollectionContent(containerData: EditionsCollection, cardId: String) = {
containerData.items.find(item => item.id == cardId && item.cardType == CardType.FeastCollection) match {
case None=>
Left(EditionsDB.NotFoundError("No Feast collection found with that ID"))
case Some(EditionsFeastCollection(sourceCollectionId, sourceCollectionMTime, sourceCollectionMeta))=>
sourceCollectionMeta match {
case None=>
Left(EditionsDB.InvalidInput("This card is not properly configured"))
case Some(meta)=>
Right(meta)
}
case _=>
Left(EditionsDB.InvalidInput("This card is not a Feast collection"))
}
}

private def lookupCollection(collectionId:String) = db.getCollections(List(GetCollectionsFilter(collectionId, None))).headOption

def feastCollectionToContainer(frontId:String, collectionId: String, collectionCardId: String) = EditEditionsAuthAction { req=>
//collectionId is the ID of the _container_ where the Feast collection card is.
// collectionCardId is the ID of the feast collection itself, which is a _card_ in terms of Fronts

lookupCollection(collectionId) match {
case None=>
NotFound
case Some(sourceContainer)=>
val result = for {
feastCollection <- getFeastCollectionContent(sourceContainer, collectionCardId)
updateData <- db.addCollectionToFront(
frontId = frontId,
user = req.user,
now = OffsetDateTime.now(),
name = feastCollection.title
)
newCollection <- db.getCollections(List(GetCollectionsFilter(updateData._2, None)))
.find(_.id==updateData._2)
.toRight(Left(EditionsDB.InvariantError("Could not find created new collection")))
_ = db.updateCollection(newCollection.copy(items=feastCollection.collectionItems))
updatedFront <- DB localTx { implicit session=>
db.getFront(frontId).toRight(EditionsDB.InvariantError("The front was deleted while processing"))
}
} yield updatedFront

result match {
case Left(EditionsDB.NotFoundError(msg))=>
NotFound(msg)
case Left(EditionsDB.InvariantError(msg))=>
Conflict(msg)
case Left(err)=>
logger.error(s"Unexpected error when converting a Feast collection into a container: $err")
InternalServerError("")
case Right(updatedFront)=>
val collections = toClientCollections(updatedFront)
Ok(Json.toJson(collections))
}
}
}
}
12 changes: 6 additions & 6 deletions app/services/editions/db/EditionsDB.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@ package services.editions.db

import java.time.{Instant, OffsetDateTime, ZoneOffset}
import java.time.temporal.ChronoUnit

import scalikejdbc._
import com.gu.pandomainauth.model.User
import model.editions.EditionsFront
import model.editions.{EditionsCard, EditionsFeastCollection, EditionsFeastCollectionMetadata, EditionsFront}

import java.util.UUID

class EditionsDB(url: String, user: String, password: String) extends IssueQueries with FrontsQueries with CollectionsQueries {
Class.forName("org.postgresql.Driver")
ConnectionPool.singleton(url, user, password)


/**
* Add a EditionsCollection to an EditionsFront at the specified index.
*
* @return the ID of the collection.
* @return tuple of the updated front and the ID of the collection.
*/
def addCollectionToFront(frontId: String, name: Option[String] = None, collectionIndex: Option[Int] = None, user: User, now: OffsetDateTime): Either[Error, EditionsFront] = DB localTx { implicit session =>
def addCollectionToFront(frontId: String, name: Option[String] = None, collectionIndex: Option[Int] = None, user: User, now: OffsetDateTime): Either[Error, (EditionsFront, String)] = DB localTx { implicit session =>
val truncatedNow = EditionsDB.truncateDateTime(now)

for {
Expand All @@ -31,7 +31,7 @@ class EditionsDB(url: String, user: String, password: String) extends IssueQueri
)
updatedFront <- getFront(frontId).toRight(EditionsDB.InvariantError(s"Updated front $frontId not found in issue"))
_ <- updatedFront.collections.find(_.id == collectionId).toRight(EditionsDB.InvariantError(s"New collection ${collectionId} not found in updated front ${frontId}"))
} yield updatedFront
} yield (updatedFront, collectionId)
}

/**
Expand Down
2 changes: 2 additions & 0 deletions conf/routes
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,5 @@ GET /editions-api/collections/:collectionId cont
PUT /editions-api/collections/:collectionId controllers.EditionsController.updateCollection(collectionId)
PATCH /editions-api/collections/:collectionId/name controllers.EditionsController.renameCollection(collectionId)
GET /editions-api/collections/:collectionId/prefill controllers.EditionsController.getPrefillForCollection(collectionId)

POST /editions-api/fronts/:frontId/collections/:collectionId/feastCollectionToContainer/:collectionCardId controllers.EditionsController.feastCollectionToContainer(frontId, collectionId, collectionCardId)
8 changes: 7 additions & 1 deletion fronts-client/src/actions/Cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const createInsertCardThunk =
if (removeAction) {
dispatch(removeAction);
}

// This cast seems to be necessary to disambiguate the type fed to Dispatch,
// whose call signature accepts either an Action or a ThunkResult. I'm not really
// sure why.
Expand Down Expand Up @@ -271,6 +272,7 @@ const insertCardWithCreate =
card,
persistTo
);

if (modifyCardAction) dispatch(modifyCardAction);

dispatch(
Expand Down Expand Up @@ -437,11 +439,15 @@ export const createArticleEntitiesFromDrop = (
isEdition,
dispatch
);

if (maybeExternalArticle) {
dispatch(externalArticleActions.fetchSuccess(maybeExternalArticle));
}
if (maybeCard) {
dispatch(cardsReceived([maybeCard]));
//if the card we are dropping has supporting cards, ensure that they travel too
const supporting = maybeCard.meta?.supporting?.map(uuid=>selectCard(getState(), uuid)) ?? [];

dispatch(cardsReceived([maybeCard, ...supporting]));
}
return maybeCard;
};
Expand Down
19 changes: 19 additions & 0 deletions fronts-client/src/actions/Editions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,25 @@ export const moveFrontCollection =
}
};

export const feastCollectionToFrontCollection =
(
frontId: string,
collectionId: string,
feastCollectionCardId: string
): ThunkResult<Promise<void>> =>
async (dispatch, getState) => {
const url = `/editions-api/fronts/${frontId}/collections/${collectionId}/feastCollectionToContainer/${feastCollectionCardId}`;
const response = await fetch(url, { method: "POST", credentials: "include" });

if(response.status===200) {
const editionsCollections = ( await response.json() ) as EditionsCollection[];
processNewEditionFrontResponse(frontId, editionsCollections, dispatch, getState);
} else {
const responseBody = await response.text();
console.error(`Unable to migrate to container - server said ${response.status} ${responseBody}`);
}
}

export const getNewCollectionIndexForMove = (
front: FrontConfig,
collectionId: string,
Expand Down
8 changes: 8 additions & 0 deletions fronts-client/src/components/CollectionDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
SUPPORT_PORTRAIT_CROPS,
} from 'constants/image';
import { AspectRatioBadge } from './icons/AspectRatioBadge';
import { DragToConvertFeastCollection } from './FrontsEdit/CollectionComponents/DragToConvertFeastCollection';
import { selectors as editionsIssueSelectors } from '../bundles/editionsIssueBundle';

export const createCollectionId = ({ id }: Collection, frontId: string) =>
`front-${frontId}-collection-${id}`;
Expand All @@ -47,6 +49,7 @@ interface ContainerProps {
id: string;
browsingStage: CardSets;
frontId: string;
isFeast?: boolean;
}

type Props = ContainerProps & {
Expand Down Expand Up @@ -257,6 +260,7 @@ class CollectionDisplay extends React.Component<Props, CollectionState> {
handleFocus,
handleBlur,
isEditions,
isFeast
}: Props = this.props;
const itemCount = cardIds ? cardIds.length : 0;
const targetedTerritory = collection ? collection.targetedTerritory : null;
Expand Down Expand Up @@ -342,6 +346,9 @@ class CollectionDisplay extends React.Component<Props, CollectionState> {
</HeadlineContentContainer>
) : null}
</CollectionHeadingInner>
{
isFeast ? <DragToConvertFeastCollection sourceContainerId={id}/> : undefined
}
</CollectionHeadingSticky>
<DragIntentContainer
delay={300}
Expand Down Expand Up @@ -433,6 +440,7 @@ const createMapStateToProps = () => {
includeSupportingArticles: false,
}),
isEditions: isMode(state, 'editions'),
isFeast: editionsIssueSelectors.selectAll(state)?.platform === 'feast',
};
};
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React, { useRef } from 'react';
import { DraggingArticleComponent } from './ArticleDrag';
import { DragToAdd } from './DragToAdd';
import { Card } from '../../../types/Collection';
import { CardTypesMap } from '../../../constants/cardTypes';
import v4 from 'uuid/v4';
import { handleDragStartForCard } from '../../../util/dragAndDrop';
import { useSelector } from 'react-redux';
import { selectors as collectionSelectors } from '../../../bundles/collectionsBundle';
import { createSelectCardsInCollection } from '../../../selectors/shared';
import { State } from '../../../types/State';

interface DragToConvertFeastCollectionProps {
sourceContainerId: string;
}

export const DragToConvertFeastCollection:React.FC<DragToConvertFeastCollectionProps> = ({sourceContainerId})=>{
const ref = useRef<HTMLDivElement>(null);

const containerInfo = useSelector(state=>collectionSelectors.selectById(state, sourceContainerId));
const cardSelector = createSelectCardsInCollection();
const cards = useSelector<State>(state=>cardSelector(state, {
collectionId: sourceContainerId,
collectionSet: "draft",
includeSupportingArticles: false
})) as string[];

const handleDragStart = (event: React.DragEvent<HTMLDivElement>) => {
if(ref.current) {
const feastCollectionCard: Card = {
cardType: CardTypesMap.FEAST_COLLECTION,
id: v4(),
meta: {
title: containerInfo?.displayName ?? "New collection",
supporting: cards,
},
uuid: v4(),
frontPublicationDate: Date.now(),
};

return handleDragStartForCard(
CardTypesMap.FEAST_COLLECTION,
feastCollectionCard
)(event, ref.current);
}
}

return (
<DragToAdd onDragStart={handleDragStart}
dragImage={<DraggingArticleComponent headline="Feast collection" />}
dragImageRef={ref}>
Drag to convert to Feast collection
</DragToAdd>
)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { Card, CardSizes } from '../../../types/Collection';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { selectCard } from '../../../selectors/shared';
import { State } from '../../../types/State';
import CardContainer from '../CardContainer';
Expand All @@ -18,6 +18,8 @@ import {
} from '../../inputs/HoverActionButtons';
import { PaletteItem } from 'components/form/PaletteForm';
import { CardPaletteContainer } from '../CardPaletteContainer';
import { HeadlineContentButton } from '../../CollectionDisplay';
import { feastCollectionToFrontCollection } from '../../../actions/Editions';

interface Props {
onDragStart?: (d: React.DragEvent<HTMLElement>) => void;
Expand Down Expand Up @@ -51,8 +53,20 @@ export const FeastCollectionCard = ({
showMeta = true,
...rest
}: Props) => {
const dispatch = useDispatch();
const card = useSelector<State, Card>((state) => selectCard(state, id));

const collectionToContainer:React.MouseEventHandler = (evt)=>{
evt.preventDefault();
evt.stopPropagation();

if(collectionId) {
dispatch<any>(feastCollectionToFrontCollection(frontId, collectionId, card.id));
} else {
console.error("Can't convert a collection into a container unless said collection is already in a container :(")
}
}

return (
<>
<CardContainer {...rest}>
Expand All @@ -64,10 +78,20 @@ export const FeastCollectionCard = ({
)}
<CardContent textSize={textSize}>
<CardHeadingContainer size={size}>
<CardHeading data-testid="headline" html>
{card.meta.title ? card.meta.title : 'No title'}
</CardHeading>
<div style={{display: "flex", justifyContent: "space-between"}}>
<div style={{flex: 0, width: "fit-content", minWidth: "fit-content"}}>
<CardHeading data-testid="headline" html>
{card.meta.title ? card.meta.title : 'No title'}
</CardHeading>
</div>
{ collectionId ? <div style={{flex: 0, width: "fit-content", minWidth: "fit-content"}}>
<HeadlineContentButton onClick={collectionToContainer}>
Convert to Container
</HeadlineContentButton>
</div> : undefined}
</div>
</CardHeadingContainer>

</CardContent>
{card.meta.feastCollectionTheme && (
<CardPaletteContainer>
Expand Down
6 changes: 3 additions & 3 deletions fronts-client/src/util/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ const getCardEntitiesFromDrop = async (
}

if (drop.type === 'FEAST_COLLECTION') {
return getFeastCollectionFromFeedDrop();
return getFeastCollectionFromFeedDrop(drop.data);
}

const droppedDataURL = drop.data.trim();
Expand Down Expand Up @@ -293,8 +293,8 @@ const getRecipeEntityFromFeedDrop = (recipe: Recipe): [Card] => {
return [card];
};

const getFeastCollectionFromFeedDrop = (): [Card] => {
return [createCard(v4(), false, { cardType: CardTypesMap.FEAST_COLLECTION })];
const getFeastCollectionFromFeedDrop = (data: Card): [Card] => {
return [data];
};

const getArticleEntitiesFromFeedDrop = (
Expand Down
Loading

0 comments on commit aad3276

Please sign in to comment.