From 156786159be888c641336ce5bc433d14c7828c00 Mon Sep 17 00:00:00 2001 From: Divya Bhatt Date: Thu, 19 Sep 2024 16:42:47 +0100 Subject: [PATCH 01/15] Correct the script name --- scripts/recipes/insert-backdate-recipes.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/recipes/insert-backdate-recipes.mjs b/scripts/recipes/insert-backdate-recipes.mjs index 5c2057440f7..97d19df5889 100644 --- a/scripts/recipes/insert-backdate-recipes.mjs +++ b/scripts/recipes/insert-backdate-recipes.mjs @@ -4,7 +4,7 @@ import { migrateIssue } from './migrate-issue.mjs'; const dateFormat = 'YYYY-MM-DD'; const usage = `Example usage is -node ./insert-recipe-cards.mjs +node ./insert-backdate-recipes.mjs --curation-path "northern" --from-date "2024-05-01" --to-date "2024-05-05" From 16390e890597a20a48195e8a22422ab8877c912c Mon Sep 17 00:00:00 2001 From: Divya Bhatt Date: Thu, 19 Sep 2024 16:46:49 +0100 Subject: [PATCH 02/15] Put encodeURI to avoid dropping part of title due to presence of special characters in it like &. --- scripts/recipes/migrate-issue.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/recipes/migrate-issue.mjs b/scripts/recipes/migrate-issue.mjs index fa11a4fd2a4..16080522e3b 100644 --- a/scripts/recipes/migrate-issue.mjs +++ b/scripts/recipes/migrate-issue.mjs @@ -99,7 +99,7 @@ async function migrateFront( // Collections are added from the top, so we add the last collection first for (const title of collectionTitlesMissingInFronts.reverse()) { const newCollectionResponse = await fetch( - `${frontsBaseUrl}/editions-api/fronts/${front.id}/collection?name=${title}`, + `${frontsBaseUrl}/editions-api/fronts/${front.id}/collection?name=${encodeURIComponent(title)}`, { method: "PUT", headers: frontsHeaders, From 761e99b621f7426a94151cd797732e8a47419909 Mon Sep 17 00:00:00 2001 From: Divya Bhatt Date: Thu, 19 Sep 2024 17:10:12 +0100 Subject: [PATCH 03/15] Remove extra templated containers from southern as well. --- .../templates/feast/FeastSouthernHemisphere.scala | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/app/model/editions/templates/feast/FeastSouthernHemisphere.scala b/app/model/editions/templates/feast/FeastSouthernHemisphere.scala index c61e9e76292..4b3b305e281 100644 --- a/app/model/editions/templates/feast/FeastSouthernHemisphere.scala +++ b/app/model/editions/templates/feast/FeastSouthernHemisphere.scala @@ -14,23 +14,12 @@ object FeastSouthernHemisphere extends FeastAppEdition { val MainFront: FrontTemplate = front( "All Recipes", - collection("Dish of the day"), - collection("Collection 2"), - collection("Collection 3"), - collection("Collection 4"), - collection("Collection 5"), - collection("Collection 6"), - collection("Collection 7"), - collection("Collection 8"), - collection("Collection 9") + collection("Dish of the day") ) val MeatFreeFront: FrontTemplate = front( "Meat-Free", - collection("Dish of the day"), - collection("Collection 2"), - collection("Collection 3"), - collection("Collection 4"), + collection("Dish of the day") ) val template: EditionTemplate = EditionTemplate( From 9c3d639c84633da22a8249bed41f66df8b2b8230 Mon Sep 17 00:00:00 2001 From: Georges Lebreton <102960844+Georges-GNM@users.noreply.github.com> Date: Fri, 20 Sep 2024 09:47:52 +0100 Subject: [PATCH 04/15] Add scrollable small to config (#1659) * Add scrollable small to config * Refactor scrollable small slice definition Co-authored-by: Charlotte Emms <43961396+cemms1@users.noreply.github.com> --------- Co-authored-by: Charlotte Emms <43961396+cemms1@users.noreply.github.com> --- app/slices/FixedContainers.scala | 4 +++- app/slices/Slice.scala | 37 ++++++++++++++++++++++++++++++++ public/src/js/modules/vars.js | 4 +++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/app/slices/FixedContainers.scala b/app/slices/FixedContainers.scala index 3e857e2ac33..8a5b63505a8 100644 --- a/app/slices/FixedContainers.scala +++ b/app/slices/FixedContainers.scala @@ -16,6 +16,7 @@ class FixedContainers(val config: ApplicationConfiguration) { val showcase = slices(ShowcaseSingleStories) val thrasher = slices(Fluid).copy(customCssClasses = Set("fc-container--thrasher")) val highlights = slices(Highlights) + val scrollableSmall = slices(ScrollableSmall) val video = slices(TTT).copy(customCssClasses = Set("fc-container--video")) val all: Map[String, ContainerDefinition] = Map( @@ -36,7 +37,8 @@ class FixedContainers(val config: ApplicationConfiguration) { ("fixed/video/vertical", video), ("fixed/thrasher", thrasher), ("fixed/showcase", showcase), - ("scrollable/highlights", highlights) + ("scrollable/highlights", highlights), + ("scrollable/small", scrollableSmall) ) ++ (if (config.faciatool.showTestContainers) Map( ("all-items/not-for-production", slices(FullMedia100, FullMedia75, FullMedia50, HalfHalf, QuarterThreeQuarter, ThreeQuarterQuarter, Hl4Half, HalfQuarterQl2Ql4, TTTL4, Ql3Ql3Ql3Ql3)) ) else Map.empty) diff --git a/app/slices/Slice.scala b/app/slices/Slice.scala index 965db4b6ccc..694e4f839d8 100644 --- a/app/slices/Slice.scala +++ b/app/slices/Slice.scala @@ -1007,3 +1007,40 @@ case object Highlights extends Slice { ), ) } + + +/* + * The Scrollable small layout is implemented via a carousel. + * The implementation on platforms limits the display to 8 cards altogether, and only 2-3 cards at a time. + * In the tool, we're satisfied with using a 8 card layout to hint at the maximum number of stories. + * + * Desktop: + * .____________.____________.____________.____________.____________.____________.____________.____________. + * | #####| #####| #####| #####| #####| #####| #####| #####| + * | #####| #####| #####| #####| #####| #####| #####| #####| + * | #####| #####| #####| #####| #####| #####| #####| #####| + * '-------------------------------------------------------------------------------------------------------' + * + * Mobile: + * .___________.___________.___________.___________.___________.___________.___________.___________. + * | | | | | | | | | + * | | | | | | | | | + * |_#########_|_#########_|_#########_|_#########_|_#########_|_#########_|_#########_|_#########_| + * |_#########_|_#########_|_#########_|_#########_|_#########_|_#########_|_#########_|_#########_| + * |_#########_|_#########_|_#########_|_#########_|_#########_|_#########_|_#########_|_#########_| + * `-----------------------------------------------------------------------------------------------' + */ +case object ScrollableSmall extends Slice { + val layout = SliceLayout( + cssClassName = "t-t-t-t-t-t-t-t", + columns = Seq(Rows( + colSpan = 1, + columns = 8, + rows = 1, + ItemClasses( + mobile = ListItem, + tablet = ListItem, + ) + )) + ) +} diff --git a/public/src/js/modules/vars.js b/public/src/js/modules/vars.js index 1befe502cf2..a98ab6fb36e 100644 --- a/public/src/js/modules/vars.js +++ b/public/src/js/modules/vars.js @@ -28,7 +28,9 @@ export function init (res) { 'standard', 'snap' ] - }); + }, + {'name': 'scrollable/small'} + ); } } From 153c68d3c45457102cc82a47188f456b5ae803de Mon Sep 17 00:00:00 2001 From: Andy Gallagher Date: Tue, 17 Sep 2024 10:32:22 +0100 Subject: [PATCH 05/15] proxied endpoint to hit recpies api with a key --- app/conf/Configuration.scala | 5 ++++ app/controllers/FaciaContentApiProxy.scala | 33 +++++++++++++++++++++- conf/routes | 2 +- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/app/conf/Configuration.scala b/app/conf/Configuration.scala index 4135e2a4b57..371b68fbdbd 100644 --- a/app/conf/Configuration.scala +++ b/app/conf/Configuration.scala @@ -80,6 +80,11 @@ class ApplicationConfiguration( lazy val host = getString("ophan.api.host") } + object recipesApi { + lazy val key = getString("recipes.api.key") + lazy val url = getString("recipes.api.url") + } + object analytics { lazy val secret = getMandatoryString("analytics.secret") } diff --git a/app/controllers/FaciaContentApiProxy.scala b/app/controllers/FaciaContentApiProxy.scala index 4a4faa7059b..76ed867de13 100644 --- a/app/controllers/FaciaContentApiProxy.scala +++ b/app/controllers/FaciaContentApiProxy.scala @@ -9,11 +9,14 @@ import play.api.libs.concurrent.Futures._ import scala.concurrent.duration._ import logging.Logging +import org.apache.pekko.util.ByteString +import play.api.mvc.{ResponseHeader, Result} +import play.api.http.HttpEntity import services.Capi import switchboard.SwitchManager import util.ContentUpgrade.rewriteBody -import scala.concurrent.ExecutionContext +import scala.concurrent.{ExecutionContext, Future} class FaciaContentApiProxy(capi: Capi, val deps: BaseFaciaControllerComponents)(implicit ec: ExecutionContext) extends BaseFaciaController(deps) with Logging { @@ -111,4 +114,32 @@ class FaciaContentApiProxy(capi: Capi, val deps: BaseFaciaControllerComponents)( } } } + + def recipesLookup() = AccessAPIAuthAction.async { request => + FaciaToolMetrics.ProxyCount.increment() + + val fixedQueryString = request.queryString.map(kv=>(kv._1, kv._2.head)) + + (config.recipesApi.url, config.recipesApi.key) match { + case (Some(baseUrl), Some(key))=> + wsClient + .url(s"$baseUrl/api/content/by-uid") + .withQueryStringParameters(fixedQueryString.toList :_*) + .withHttpHeaders("X-Api-Key" -> key) + .get() + .withTimeout(5.seconds) + .map { response => + Cached(300) { + Result( + header = ResponseHeader(response.status, response.headers.map(kv=>(kv._1, kv._2.head))), + body = HttpEntity.Strict(ByteString(response.body), Some("utf-8")) + ) + } + } + case _=> + Future.successful( + InternalServerError("""Server is misconfigured, no recipes api config available""").as("application/json") + ) + } + } } diff --git a/conf/routes b/conf/routes index c5d7ea282e1..d46bc4469b4 100644 --- a/conf/routes +++ b/conf/routes @@ -69,7 +69,7 @@ GET /api/live/*path controllers.FaciaContentApi GET /http/proxy/*url controllers.FaciaContentApiProxy.http(url) GET /json/proxy/*absUrl controllers.FaciaContentApiProxy.json(absUrl) GET /ophan/*path controllers.FaciaContentApiProxy.ophan(path) - +GET /recipes/api/content/by-uid controllers.FaciaContentApiProxy.recipesLookup() # thumbnails GET /thumbnails/*id.svg controllers.ThumbnailController.container(id) From d53d8151f345905de1d848c01d57bb8db7e55a9e Mon Sep 17 00:00:00 2001 From: Andy Gallagher Date: Tue, 17 Sep 2024 14:57:22 +0100 Subject: [PATCH 06/15] wire it into the frontend and debug --- app/controllers/FaciaContentApiProxy.scala | 4 ++-- conf/logback.xml | 1 - fronts-client/src/services/recipeQuery.ts | 21 +++++++++++++-------- fronts-client/src/types/Recipe.ts | 14 ++++++++++++++ 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/app/controllers/FaciaContentApiProxy.scala b/app/controllers/FaciaContentApiProxy.scala index 76ed867de13..34ddaee878d 100644 --- a/app/controllers/FaciaContentApiProxy.scala +++ b/app/controllers/FaciaContentApiProxy.scala @@ -131,8 +131,8 @@ class FaciaContentApiProxy(capi: Capi, val deps: BaseFaciaControllerComponents)( .map { response => Cached(300) { Result( - header = ResponseHeader(response.status, response.headers.map(kv=>(kv._1, kv._2.head))), - body = HttpEntity.Strict(ByteString(response.body), Some("utf-8")) + header = ResponseHeader(response.status, Map.empty), + body = HttpEntity.Strict(ByteString(response.body), Some("application/json")) ) } } diff --git a/conf/logback.xml b/conf/logback.xml index 0546b96c480..18ee2627f2d 100644 --- a/conf/logback.xml +++ b/conf/logback.xml @@ -32,5 +32,4 @@ - diff --git a/fronts-client/src/services/recipeQuery.ts b/fronts-client/src/services/recipeQuery.ts index f22b6e65dac..3cd774825fa 100644 --- a/fronts-client/src/services/recipeQuery.ts +++ b/fronts-client/src/services/recipeQuery.ts @@ -1,5 +1,5 @@ import url from '../constants/url'; -import { Recipe } from '../types/Recipe'; +import { Recipe, RecipePartialIndexContent } from '../types/Recipe'; interface KeyAndCount { key: string; @@ -163,14 +163,19 @@ const recipeQuery = (baseUrl:string) => { } }, recipesById: async (idList:string[]):Promise => { - const responses = await Promise.all( - idList.map(id=>fetch(`${baseUrl}/search/uid/${id}`, - { - redirect: "follow" - })) - ); + console.log(`Fetching ${idList.length} recipes`); + const indexResponse = await fetch(`/recipes/api/content/by-uid?ids=${idList.join(",")}`, { + credentials: "include" + }); + if(indexResponse.status != 200) { + throw new Error(`Unable to retrieve partial index: server error ${indexResponse.status}`); + } - const successes = responses.filter((_)=>_.status===200); + const content = (await indexResponse.json()) as RecipePartialIndexContent; + const recipeResponses = await Promise.all( + content.results.map(entry=>fetch(`${baseUrl}/content/${entry.checksum}`)) + ); + const successes = recipeResponses.filter((_)=>_.status===200); return Promise.all( successes.map((_)=>_.json()) ) as Promise diff --git a/fronts-client/src/types/Recipe.ts b/fronts-client/src/types/Recipe.ts index e299d6a91bd..6db3923ce13 100644 --- a/fronts-client/src/types/Recipe.ts +++ b/fronts-client/src/types/Recipe.ts @@ -22,3 +22,17 @@ export interface Recipe { featuredImage?: RecipeImage; // the latter is an old image format that appears in our test fixtures previewImage?: RecipeImage; } + +export interface RecipeIndexData { + checksum: string; + recipeUID: string; + capiArticleId: string; + sponsorshipCount?: number; +} + +export interface RecipePartialIndexContent { + status: string; + resolved: number; + requested: number; + results: RecipeIndexData[]; +} From c843e28c69a628335175de4c6a5953f355120e8c Mon Sep 17 00:00:00 2001 From: Andy Gallagher Date: Tue, 17 Sep 2024 16:04:28 +0100 Subject: [PATCH 07/15] add a script that can generate CODE fronts with CODE recipes in them --- scripts/recipes/recipe-front-generator.mjs | 334 +++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100755 scripts/recipes/recipe-front-generator.mjs diff --git a/scripts/recipes/recipe-front-generator.mjs b/scripts/recipes/recipe-front-generator.mjs new file mode 100755 index 00000000000..a3114bf2efa --- /dev/null +++ b/scripts/recipes/recipe-front-generator.mjs @@ -0,0 +1,334 @@ +#!/usr/bin/env node + +//A preset list of containers that we can select from +import crypto from 'crypto'; + +const usage = `This script builds kinda sensible-looking fronts based on a set list of titles +and semantic search. + +Example usage is + +./recipe-front-generator.mjs + --stage LOCAL + --fronts-issue-id 9d58078c-f9d8-4c27-8949-5c1dc8d2bfe5 + --front-name 'All Recipes' + --cookie "$AUTH_COOKIE" + --collection-count 7 + +stage - set this to LOCAL or CODE. Don't run against PROD. +fronts-issue-id - the issue containing the fronts to populate. Get this from the browser address bar in Fronts tool (e.g., https://fronts.local.dev-gutools.co.uk/v2/issues/9d58078c-f9d8-4c27-8949-5c1dc8d2bfe5) +front-name - either 'All Recipes' or 'Meat-Free'. Note that it needs to exist already. +cookie - set of cookies containing authorization. Get this by going into the Network tab, reloading your Front, finding a network request and copying the headers. +I tend to then set this into an environment variable to make by console buffer more readable +collection-count - number of collections to generate. +`; + +const containerNames = [ + "Flavors of the World: A Culinary Adventure", + "Quick & Tasty: Meals in 30 Minutes or Less", + "Cozy Comfort: Recipes for Rainy Days", + "Fresh & Light: A Taste of Summer", + "Farm to Table: Fresh, Seasonal Delights", + "Spice It Up: Bold Dishes for Adventurous Eaters", + "Soul-Warming Stews for Cold Nights", + "From the Oven: Bakes to Satisfy Any Craving", + "Simply Delicious: 5-Ingredient Wonders", + "One-Pot Magic: Easy Dishes, Minimal Cleanup", + "Plant-Powered: Vibrant Vegan Creations", + "Sweet Tooth Heaven: Desserts to Indulge In", + "Family Favorites: Meals to Make Everyone Smile", + "Weekend Brunch Goals: Recipes to Impress", + "The Italian Kitchen: Pasta, Pizza, and More", + "Healthy, Wholesome, & Hearty Bowls", + "Sizzle & Sear: Grilled Goodness All Year Long", + "Bringing the Heat: Fiery Flavors You'll Love", + "Sweet & Savory Fusion: Unique Flavor Combos", + "Global Comfort Foods: Your Favorite Dishes Reimagined", + "Deliciously Decadent: Indulge in Every Bite", + "Street Food Staples from Around the World", + "Feast Your Eyes: Gourmet Meals Made Easy", + "Healthy Habits: Nutritious Meals That Satisfy", + "Hearty & Homestyle: Classic Comfort Dishes", + "Elevated Everyday: Simple Meals, Sophisticated Taste", + "Dinner Party Perfection: Dishes to Impress Guests", + "Mediterranean Marvels: Fresh and Flavorful", + "Master the Grill: Recipes for BBQ Lovers", + "Crispy, Crunchy, & Full of Flavor", + "Sweet Beginnings: Breakfast & Brunch Treats", + "Summer BBQ Essentials: Flame-Kissed Goodness", + "Flavors of Fall: Seasonal Recipes to Savor", + "Aromatic & Rich: Perfect Curry Recipes", + "Simple Snacks: Tasty Bites for Every Occasion", + "Coastal Cooking: Seafood Recipes to Dive Into", + "Warming Soups & Stews for Every Season", + "Quick Bites: Appetizers for Any Occasion", + "Baked to Perfection: Savory & Sweet Delights", + "Wrap It Up: Easy and Delicious Wrap Recipes", + "Delicious Detox: Clean Eating Recipes", + "The Sweetest Treats: Baking Bliss Awaits", + "Bold Flavors, Simple Prep: Quick Gourmet Meals", + "Ultimate Game Day Grub: Crowd-Pleasing Snacks", + "Feel-Good Foods: Healthy and Hearty", + "Satisfy Your Cravings: Comfort Foods Redefined", + "Light & Lovely: Perfect Salads for Any Meal", + "Gluten-Free Goodies Everyone Will Love", + "Breakfast in Bed: Recipes to Start the Day Right", + "Ultimate Meat Lover’s Menu", + "Under 500 Calories: Guilt-Free Gourmet", + "For the Love of Chocolate: Irresistible Desserts", + "Easy Entertaining: No-Fuss Party Foods", + "Satisfying Sides: Perfect Complements to Any Meal", + "Asian Fusion Feasts: Bold, Unique Flavors", + "Lighter Fare: Meals That Won't Weigh You Down", + "Sundays Made Simple: Slow Cooker Comfort", + "Finger Food Fun: Deliciously Dippable Recipes", + "Quick Fix: Weeknight Meals in a Flash", + "Savory Sensations: Satisfying Soups to Savor", + "Rustic Elegance: Country-Inspired Recipes", + "Savory & Sweet: Perfect Pairings for Every Palate", + "Tacos & Tequila: Mexican-Inspired Meals", + "Lunchbox Love: Easy Meals to Take On-the-Go", + "A Taste of the Tropics: Exotic Island Flavors", + "Superfoods for Super You: Power-Packed Plates", + "Midnight Munchies: Late Night Snacks You’ll Love", + "Guilt-Free Desserts You Can’t Resist", + "Pizza Party: Creative and Fun Toppings", + "Fiesta Flavors: Mexican Favorites You’ll Adore", + "Savory Bites: Delicious Dinner Ideas", + "One-Pan Wonders: Fuss-Free Cooking", + "Hearty Breakfasts to Fuel Your Day", + "Tapas & Small Plates: Bite-Sized Bliss", + "Picnic Perfection: Easy, Portable Recipes", + "On a Roll: Perfect Sandwiches and Wraps", + "Cheesy Comforts: Melty, Gooey Delights", + "Heavenly Homestyle Baking: Recipes to Cherish", + "Fresh From the Garden: Herb & Veggie-Packed Dishes", + "A Taste of Italy: Recipes for Italian Food Lovers", + "Fiery & Flavorful: Spicy Dishes to Heat Things Up", + "Indulgent & Irresistible: Rich Dishes to Savor", + "Refreshing & Light: Drinks and Smoothies to Sip", + "Quick, Easy, & Delicious Breakfast Ideas", + "Creamy & Dreamy: Comforting Pasta Dishes", + "Flourless Feasts: Gluten-Free Wonders", + "Slow-Cooked Success: Low & Slow, Big Flavor", + "Bite-Sized Bliss: Perfect Hors d'Oeuvres", + "A Dash of Citrus: Zesty Recipes That Shine", + "Hearty Grain Bowls for Everyday Energy", + "Sizzle & Spice: Southeast Asian Sensations", + "Savory Pies: Perfect for Every Meal", + "Breakfast Boost: Start Your Day Right", + "Farmhouse Flavors: Rustic Recipes That Warm the Heart", + "Satisfying Smoothies for Anytime", + "Decadent Dinners: Treat Yourself Tonight", + "Savory Brunch Ideas for Lazy Mornings", + "Flour Power: Master the Art of Baking", + "Festive Feasts: Holiday Recipes for Celebration", + "Healthy Starts: Energizing Breakfast Recipes", + "Global Grains: A World of Delicious Grains", + "Perfect for Sharing: Family-Style Meals", + "Sweet & Savory Creations for Any Mood" +] +const recipeBase = "https://recipes.code.dev-guardianapis.com"; +const getArg = (flag, optional = false) => { + const argIdx = process.argv.indexOf(flag); + const arg = argIdx !== -1 ? process.argv[argIdx + 1] : undefined; + + if (!arg && !optional) { + console.error(`No argument for ${flag} given. ${usage}`); + process.exit(2); + } + + return arg; +}; + +class ContinueOnError extends Error { + +} + +const getFrontsUri = () => { + switch(stage.toLocaleUpperCase()) { + case "PROD": + throw new Error("Don't run this against PROD") + case "CODE": + return "https://fronts.code.dev-gutools.co.uk"; + case "LOCAL": + return "https://fronts.local.dev-gutools.co.uk"; + default: + throw new Error("--stage must be one of PROD, CODE or LOCAL") + } +} + +const stage = getArg("--stage"); +const frontsBaseUrl = getFrontsUri(); +const frontsIssueId = getArg("--fronts-issue-id"); +const collectionCount = parseInt(getArg("--collection-count", true) ?? "1"); +const minRecipes = 4; +const maxRecipes = 10; +const frontName = getArg("--front-name"); +const cookie = getArg("--cookie"); +const frontsHeaders = { + "Content-Type": "application/json", + Cookie: cookie, +}; + +/** + * Returns a batch of recipes from the search backend, in this format: + * { + * "hits": 61, + * "maxScore": 0.8703575, + * "results": [ + * { + * "score": 0.8703575, + * "title": "Courgette and samphire", + * "href": "/content/hSase0evm9VrxXxt9SP8_HWpDmpFQ9_I_rK_mC8S1aw", + * "composerId": "57864e17e4b02d747b53cae5" + * }, + * .... + * } + * @param searchString + * @param count + * @return {Promise} + */ +async function findRecipes(searchString, count) { + const response = await fetch(`${recipeBase}/search?q=${encodeURIComponent(searchString)}&format=Full&limit=${count}`); + if(response.status !== 200) { + const content = await response.text(); + console.error(`Server error ${response.status}: ${content}`); + throw new Error("Unable to search for matching recipes") + } + return response.json(); +} + +/** + * Takes in a recipe index structure and turns it into a recipe card + * @param recipeIndexEntry + */ +function buildCard(recipeIndexEntry) { + if(!recipeIndexEntry.id) throw new Error("Can't build card as recipeIndexEntry has no id"); + return { + uuid: crypto.randomUUID(), + frontPublicationDate: Date.now(), + cardType: 'recipe', + id: recipeIndexEntry.id + } +} + +/** + * Creates a new collection in the given front + * @param collectionName + * @param frontId + * @return {Promise<*>} the ID of the collection that was just created + */ +async function makeNewCollection(collectionName, frontId) { + const newCollectionResponse = await fetch( + `${frontsBaseUrl}/editions-api/fronts/${frontId}/collection?name=${encodeURIComponent(collectionName)}`, + { + method: "PUT", + headers: frontsHeaders, + } + ); + + if (newCollectionResponse.status !== 200) { + console.error( + `Error creating new collection: ${ + newCollectionResponse.status + } ${ + newCollectionResponse.statusText + } ${await newCollectionResponse.text()}` + ); + throw new Error("Unable to create collection") + } else { + console.log( + `Collection with title '${collectionName}' added to front ${frontId}` + ); + } + + const responseBody = await newCollectionResponse.json(); + //We always insert a new collection at the top + return responseBody[0].id; +} + +async function updateCollectionContents(collectionId, collectionName, cards) { + const body = JSON.stringify({ + collection: { + id: collectionId, + isHidden: false, + lastUpdated: Date.now(), + updatedBy: "autofill script", + updatedEmail: "andy.gallagher@guardian.co.uk", + displayName: collectionName, + items: cards + }, + id: collectionId + }) + const response = await fetch( + `${frontsBaseUrl}/editions-api/collections/${collectionId}`, + { + method: "PUT", + headers: frontsHeaders, + body, + } + ); + + if(response.status!==200) { + const contentText = await response.text(); + throw new Error(`Unable to update collection with ID ${collectionId}, server said ${response.status} ${contentText}`) + } + + return response.json(); +} + +async function buildCollection(collectionName, frontId, count) { + const recipes = await findRecipes(collectionName, count); //use the collectionName as a search string + if(recipes.maxScore < 0.7) { + throw new ContinueOnError(`No reliable results for '${collectionName} as a search string`); + } + console.log(`Selected ${recipes.results.length} recipes with max confidence of ${recipes.maxScore}`); + recipes.results.forEach((r)=>console.log(`\t${r.title} ${r.contributors}`)); + + const newCollectionId = await makeNewCollection(collectionName, frontId); + const recipeCards = recipes.results.map(buildCard); + await updateCollectionContents(newCollectionId, collectionName, recipeCards); +} + +async function frontNameToId(issueId, frontName) { + const response = await fetch( + `${frontsBaseUrl}/editions-api/issues/${issueId}`, + { + method: "GET", + headers: frontsHeaders, + } + ); + if(response.status!==200) { + const contentText = await response.text(); + throw new Error(`Unable to map name to ID, server said ${response.status} ${contentText}`); + } + const content = await response.json(); + const matches = content.fronts.filter((_)=>_.displayName===frontName); + if(matches.length>0) { + return matches[0].id; + } else { + return undefined; + } +} + +// START MAIN +const frontId = await frontNameToId(frontsIssueId, frontName); +console.log(`ID of front ${frontName} is ${frontId}. Looking to generate ${collectionCount} collections`); + +for(let i=0; i Date: Tue, 17 Sep 2024 16:07:02 +0100 Subject: [PATCH 08/15] Up the maximum number of recipes to put in a container --- scripts/recipes/recipe-front-generator.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/recipes/recipe-front-generator.mjs b/scripts/recipes/recipe-front-generator.mjs index a3114bf2efa..62f1c108350 100755 --- a/scripts/recipes/recipe-front-generator.mjs +++ b/scripts/recipes/recipe-front-generator.mjs @@ -163,7 +163,7 @@ const frontsBaseUrl = getFrontsUri(); const frontsIssueId = getArg("--fronts-issue-id"); const collectionCount = parseInt(getArg("--collection-count", true) ?? "1"); const minRecipes = 4; -const maxRecipes = 10; +const maxRecipes = 40; const frontName = getArg("--front-name"); const cookie = getArg("--cookie"); const frontsHeaders = { From 638df9c1dab94f7da1779fde0172328d4a1ba6c5 Mon Sep 17 00:00:00 2001 From: Andy Gallagher Date: Tue, 17 Sep 2024 17:32:04 +0100 Subject: [PATCH 09/15] hopefully increase the search accuracy a bit --- scripts/recipes/recipe-front-generator.mjs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/recipes/recipe-front-generator.mjs b/scripts/recipes/recipe-front-generator.mjs index 62f1c108350..240a3cc6d3f 100755 --- a/scripts/recipes/recipe-front-generator.mjs +++ b/scripts/recipes/recipe-front-generator.mjs @@ -20,7 +20,7 @@ fronts-issue-id - the issue containing the fronts to populate. Get this from the front-name - either 'All Recipes' or 'Meat-Free'. Note that it needs to exist already. cookie - set of cookies containing authorization. Get this by going into the Network tab, reloading your Front, finding a network request and copying the headers. I tend to then set this into an environment variable to make by console buffer more readable -collection-count - number of collections to generate. +collection-count - number of collections to generate. Defaults to 1 if not specified. `; const containerNames = [ @@ -190,6 +190,7 @@ const frontsHeaders = { * @return {Promise} */ async function findRecipes(searchString, count) { + console.debug(`search term is '${searchString}'`) const response = await fetch(`${recipeBase}/search?q=${encodeURIComponent(searchString)}&format=Full&limit=${count}`); if(response.status !== 200) { const content = await response.text(); @@ -278,8 +279,17 @@ async function updateCollectionContents(collectionId, collectionName, cards) { return response.json(); } +function searchTermFromCollectionName(collectionName) { + const indexOfColon = collectionName.lastIndexOf(':'); + if(indexOfColon>0) { + return collectionName.substring(indexOfColon+1).trim(); + } else { + return collectionName; + } +} + async function buildCollection(collectionName, frontId, count) { - const recipes = await findRecipes(collectionName, count); //use the collectionName as a search string + const recipes = await findRecipes(searchTermFromCollectionName(collectionName), count); //use the collectionName as a search string if(recipes.maxScore < 0.7) { throw new ContinueOnError(`No reliable results for '${collectionName} as a search string`); } From e241fb9fa977c881b73b7a66c93ec35038a733ea Mon Sep 17 00:00:00 2001 From: Andy Gallagher Date: Tue, 17 Sep 2024 17:52:38 +0100 Subject: [PATCH 10/15] test added frontend code --- .../services/__tests__/recipeQuery.spec.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/fronts-client/src/services/__tests__/recipeQuery.spec.ts b/fronts-client/src/services/__tests__/recipeQuery.spec.ts index 4babec73b81..e125c827d6e 100644 --- a/fronts-client/src/services/__tests__/recipeQuery.spec.ts +++ b/fronts-client/src/services/__tests__/recipeQuery.spec.ts @@ -169,4 +169,92 @@ describe("recipeQueries.recipes", ()=>{ expect(response.maxScore).toEqual(0.8); }); +}); + +describe("recipeQueries.recipesById", ()=>{ + beforeEach(()=>{ + fetchMock.restore(); + }); + + it("should load in a batch of recipes", async ()=>{ + const idList = ['2bf50440adfff3b634fe471dfb778b21a1353787','33a8c9a9609fada8ec28a9be1e068d8d01cae530','72603cd9828849729fd50bc4b3dd6f1b']; + + fetchMock + .get("begin:/recipes/api/content/by-uid?ids=", { + "status": "ok", + "resolved": 40, + "requested": 40, + "results": [ + { + "checksum": "FCPXAy6wwnLHs6t3oYNk5hu_CpzF2ECgCfdrX_G67AQ", + "recipeUID": "2bf50440adfff3b634fe471dfb778b21a1353787", + "capiArticleId": "lifeandstyle/2015/oct/10/lentil-recipes-get-ahead-urban-rajah-ivor-peters", + "sponsorshipCount": 0 + }, + { + "checksum": "UBVUs056cPnicu2GbbA479qpZCO2CHJZmnSYglFfTxQ", + "recipeUID": "33a8c9a9609fada8ec28a9be1e068d8d01cae530", + "capiArticleId": "lifeandstyle/2018/jan/06/egg-recipes-yotam-ottolenghi-harissa-manchego-omelette-scrambled-croque-madame", + "sponsorshipCount": 0 + }, + { + "checksum": "Mi3FUm1TVfCK45Y24ZmFvQbWMZ-NTQHRxBacVgwYUw8", + "recipeUID": "72603cd9828849729fd50bc4b3dd6f1b", + "capiArticleId": "food/2024/apr/21/sticky-aubergine-tart-sea-bass-pistachio-pesto-baklava-cheesecake-greekish-recipes-georgina-hayden", + "sponsorshipCount": 0 + } + ] + }).get("https://recipes.guardianapis.com/content/FCPXAy6wwnLHs6t3oYNk5hu_CpzF2ECgCfdrX_G67AQ", { + id: '2bf50440adfff3b634fe471dfb778b21a1353787', + }).get("https://recipes.guardianapis.com/content/UBVUs056cPnicu2GbbA479qpZCO2CHJZmnSYglFfTxQ", { + id: '33a8c9a9609fada8ec28a9be1e068d8d01cae530', + }).get("https://recipes.guardianapis.com/content/Mi3FUm1TVfCK45Y24ZmFvQbWMZ-NTQHRxBacVgwYUw8", { + id: '72603cd9828849729fd50bc4b3dd6f1b', + }); + + const results = await liveRecipes.recipesById(idList); + expect(results.length).toEqual(3); + expect(results.map((_)=>_.id)).toEqual(idList); + }); + + it("should not panic if a recipe can't be found", async ()=>{ + const idList = ['2bf50440adfff3b634fe471dfb778b21a1353787','33a8c9a9609fada8ec28a9be1e068d8d01cae530','72603cd9828849729fd50bc4b3dd6f1b']; + + fetchMock + .get("begin:/recipes/api/content/by-uid?ids=", { + "status": "ok", + "resolved": 40, + "requested": 40, + "results": [ + { + "checksum": "FCPXAy6wwnLHs6t3oYNk5hu_CpzF2ECgCfdrX_G67AQ", + "recipeUID": "2bf50440adfff3b634fe471dfb778b21a1353787", + "capiArticleId": "lifeandstyle/2015/oct/10/lentil-recipes-get-ahead-urban-rajah-ivor-peters", + "sponsorshipCount": 0 + }, + { + "checksum": "UBVUs056cPnicu2GbbA479qpZCO2CHJZmnSYglFfTxQ", + "recipeUID": "33a8c9a9609fada8ec28a9be1e068d8d01cae530", + "capiArticleId": "lifeandstyle/2018/jan/06/egg-recipes-yotam-ottolenghi-harissa-manchego-omelette-scrambled-croque-madame", + "sponsorshipCount": 0 + }, + { + "checksum": "Mi3FUm1TVfCK45Y24ZmFvQbWMZ-NTQHRxBacVgwYUw8", + "recipeUID": "72603cd9828849729fd50bc4b3dd6f1b", + "capiArticleId": "food/2024/apr/21/sticky-aubergine-tart-sea-bass-pistachio-pesto-baklava-cheesecake-greekish-recipes-georgina-hayden", + "sponsorshipCount": 0 + } + ] + }).get("https://recipes.guardianapis.com/content/FCPXAy6wwnLHs6t3oYNk5hu_CpzF2ECgCfdrX_G67AQ", { + id: '2bf50440adfff3b634fe471dfb778b21a1353787', + }).get("https://recipes.guardianapis.com/content/UBVUs056cPnicu2GbbA479qpZCO2CHJZmnSYglFfTxQ", { + status: 404, + }).get("https://recipes.guardianapis.com/content/Mi3FUm1TVfCK45Y24ZmFvQbWMZ-NTQHRxBacVgwYUw8", { + id: '72603cd9828849729fd50bc4b3dd6f1b', + }); + + const results = await liveRecipes.recipesById(idList); + expect(results.length).toEqual(2); + expect(results.map((_)=>_.id)).toEqual(['2bf50440adfff3b634fe471dfb778b21a1353787','72603cd9828849729fd50bc4b3dd6f1b']); + }) }) From 273f584f704557638c37e8e6be14b83f413b19f1 Mon Sep 17 00:00:00 2001 From: Andy Gallagher Date: Tue, 17 Sep 2024 18:07:04 +0100 Subject: [PATCH 11/15] get around 414 URI Too Long errors by batching --- fronts-client/src/services/recipeQuery.ts | 42 +++++++++++++++-------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/fronts-client/src/services/recipeQuery.ts b/fronts-client/src/services/recipeQuery.ts index 3cd774825fa..e7835d1fd95 100644 --- a/fronts-client/src/services/recipeQuery.ts +++ b/fronts-client/src/services/recipeQuery.ts @@ -163,22 +163,36 @@ const recipeQuery = (baseUrl:string) => { } }, recipesById: async (idList:string[]):Promise => { - console.log(`Fetching ${idList.length} recipes`); - const indexResponse = await fetch(`/recipes/api/content/by-uid?ids=${idList.join(",")}`, { - credentials: "include" - }); - if(indexResponse.status != 200) { - throw new Error(`Unable to retrieve partial index: server error ${indexResponse.status}`); + const doTheFetch = async (idsToFind:string[]) => { + + const indexResponse = await fetch(`/recipes/api/content/by-uid?ids=${idsToFind.join(",")}`, { + credentials: "include" + }); + if (indexResponse.status != 200) { + throw new Error(`Unable to retrieve partial index: server error ${indexResponse.status}`); + } + + const content = (await indexResponse.json()) as RecipePartialIndexContent; + const recipeResponses = await Promise.all( + content.results.map(entry => fetch(`${baseUrl}/content/${entry.checksum}`)) + ); + const successes = recipeResponses.filter((_) => _.status === 200); + return Promise.all( + successes.map((_) => _.json()) + ) as Promise + } + + const recurseTheList = async (idsToFind:string[], prevResults:Recipe[]):Promise => { + const thisBatch = idsToFind.slice(0, 50); //we need to avoid a 414 URI Too Long error so batch into 50s + const results = (await doTheFetch(thisBatch)).concat(prevResults); + if(thisBatch.length==idsToFind.length) { //we finished the list + return results; + } else { + return recurseTheList(idsToFind.slice(50), results); + } } - const content = (await indexResponse.json()) as RecipePartialIndexContent; - const recipeResponses = await Promise.all( - content.results.map(entry=>fetch(`${baseUrl}/content/${entry.checksum}`)) - ); - const successes = recipeResponses.filter((_)=>_.status===200); - return Promise.all( - successes.map((_)=>_.json()) - ) as Promise + return recurseTheList(idList, []); } } } From 97e7a6f8b0a9fa6c17c0ea2fc996b8daa9090fb7 Mon Sep 17 00:00:00 2001 From: Andy Gallagher Date: Fri, 20 Sep 2024 14:26:53 +0100 Subject: [PATCH 12/15] Update recipe-front-generator.mjs Remove reference to PROD --- scripts/recipes/recipe-front-generator.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/recipes/recipe-front-generator.mjs b/scripts/recipes/recipe-front-generator.mjs index 240a3cc6d3f..644805afb3e 100755 --- a/scripts/recipes/recipe-front-generator.mjs +++ b/scripts/recipes/recipe-front-generator.mjs @@ -3,7 +3,7 @@ //A preset list of containers that we can select from import crypto from 'crypto'; -const usage = `This script builds kinda sensible-looking fronts based on a set list of titles +const usage = `This script (aka Kitchen Ipsum Generator :-D) builds kinda sensible-looking fronts based on a set list of titles and semantic search. Example usage is @@ -154,7 +154,7 @@ const getFrontsUri = () => { case "LOCAL": return "https://fronts.local.dev-gutools.co.uk"; default: - throw new Error("--stage must be one of PROD, CODE or LOCAL") + throw new Error("--stage must be one of CODE or LOCAL") } } From 7f0c0f53a7d72a613a43fbe71f86d7991f057fb9 Mon Sep 17 00:00:00 2001 From: Divya Bhatt Date: Fri, 20 Sep 2024 15:13:31 +0100 Subject: [PATCH 13/15] Warn to confirm one more time before delete collection. --- .../CollectionComponents/Collection.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/fronts-client/src/components/FrontsEdit/CollectionComponents/Collection.tsx b/fronts-client/src/components/FrontsEdit/CollectionComponents/Collection.tsx index 3e6f291ea6c..11d3239da3b 100644 --- a/fronts-client/src/components/FrontsEdit/CollectionComponents/Collection.tsx +++ b/fronts-client/src/components/FrontsEdit/CollectionComponents/Collection.tsx @@ -98,6 +98,7 @@ interface CollectionState { showOpenFormsWarning: boolean; isPreviouslyOpen: boolean; isLaunching: boolean; + isDeleteClicked?: boolean; } const PreviouslyCollectionContainer = styled.div``; @@ -166,6 +167,7 @@ class Collection extends React.Component { isPreviouslyOpen: false, isLaunching: false, showOpenFormsWarning: false, + isDeleteClicked: false, }; // added to prevent setState call on unmounted component @@ -277,8 +279,11 @@ class Collection extends React.Component { this.removeFrontCollection()} + onClick={this.handleDeleteClick} title="Delete the collection for this issue" + style={{ + color: this.state.isDeleteClicked ? 'red' : 'white', + }} > Delete @@ -381,6 +386,17 @@ class Collection extends React.Component { private removeFrontCollection = () => { this.props.removeFrontCollection(this.props.frontId, this.props.id); }; + + private handleDeleteClick = () => { + this.setState({ isDeleteClicked: true }); + const isConfirm = window.confirm( + `Are you sure you wish to delete collection? This cannot be undone.` + ); + if (isConfirm) { + this.removeFrontCollection(); + } + this.setState({ isDeleteClicked: false }); + }; } const createMapStateToProps = () => { From 42c074d067213ccc897d5853bea76f35a1af1cc6 Mon Sep 17 00:00:00 2001 From: Tom Richards Date: Thu, 19 Sep 2024 10:58:58 +0100 Subject: [PATCH 14/15] add recrop button for trail images which loads the grid modal on the existing image ready to create a new crop (with any ratio constraints) --- .../src/components/inputs/InputImage.tsx | 73 ++++++++++++++----- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/fronts-client/src/components/inputs/InputImage.tsx b/fronts-client/src/components/inputs/InputImage.tsx index 593400d93c6..3b30dc937ca 100644 --- a/fronts-client/src/components/inputs/InputImage.tsx +++ b/fronts-client/src/components/inputs/InputImage.tsx @@ -24,6 +24,7 @@ import { AddImageIcon, VideoIcon, WarningIcon, + CropIcon, } from '../icons/Icons'; import imageDragIcon from 'images/icons/image-drag-icon.svg'; import { @@ -49,9 +50,10 @@ const AddImageButton = styled(ButtonDefault)<{ small?: boolean }>` small ? theme.colors.greyVeryLight : '#5e5e5e99'}; } width: 100%; - height: 100%; + flex-grow: 1; padding: 0; text-shadow: 0 0 2px black; + display: inline-block; `; const ImageComponent = styled.div<{ @@ -92,6 +94,7 @@ const AddImageViaGridModalButton = styled.div` justify-content: center; align-items: center; flex-grow: 1; + flex-direction: column; `; const AddImageViaUrlInput = styled(InputContainer)` @@ -247,6 +250,7 @@ interface ComponentState { imageSrc: string; confirmDelete: boolean; cancelDeleteTimeout: undefined | (() => void); + isRecropping: boolean; } const dragImage = new Image(); @@ -280,12 +284,11 @@ class InputImage extends React.Component { const { small = false, input, - gridUrl, + gridUrl:gridBaseUrl, useDefault, defaultImageUrl, message = 'Replace image', hasVideo, - editMode, disabled, isSelected, isInvalid, @@ -294,7 +297,7 @@ class InputImage extends React.Component { const imageDims = this.getCurrentImageDimensions(); - if (!gridUrl) { + if (!gridBaseUrl) { return (
gridUrl config value missing @@ -302,14 +305,22 @@ class InputImage extends React.Component { ); } - const gridSearchUrl = - editMode === 'editions' ? `${gridUrl}` : this.criteriaToGridUrl(); + const hasImage = !useDefault && !!input.value && !!input.value.thumb; const imageUrl = !useDefault && input.value && input.value.thumb ? input.value.thumb : defaultImageUrl; + // e.g. https://media.guim.co.uk/db6bf997dee6d43f8dca1ab9cd2c7402725434b6/0_214_3960_2376/500.jpg + const maybeDefaultImagePathParts = defaultImageUrl && new URL(defaultImageUrl).pathname.split("/"); + const maybeDefaultImageId = maybeDefaultImagePathParts?.[1] // pathname starts with / so index 0 is empty string + const maybeDefaultCropId = maybeDefaultImagePathParts?.[2] + const gridUrl = this.state.isRecropping && maybeDefaultImageId && maybeDefaultCropId + ? `${gridBaseUrl}/images/${maybeDefaultImageId}/crop?seedCropId=${maybeDefaultCropId}&` + : `${gridBaseUrl}?`; + const gridModalUrl = `${gridUrl}${new URLSearchParams(this.criteriaToGridQueryParams()).toString()}` + const portraitImage = !!( !useDefault && imageDims && @@ -327,7 +338,7 @@ class InputImage extends React.Component { confirmDelete={this.state.confirmDelete} > { {!!small ? null : } + + + {!!small ? null : } + )} {hasVideo && useDefault && ( @@ -554,8 +574,8 @@ class InputImage extends React.Component { window.removeEventListener('message', this.onMessage, false); }; - private openModal = () => { - this.setState({ modalOpen: true }); + private openModal = (isRecropping: boolean) => () => { + this.setState({ modalOpen: true, isRecropping }); window.addEventListener('message', this.onMessage, false); }; @@ -566,20 +586,37 @@ class InputImage extends React.Component { ); }; - private criteriaToGridUrl = (): string => { - const { criteria, gridUrl } = this.props; + private criteriaToGridQueryParams = (): Record => { + const { criteria, editMode } = this.props; + + if(editMode === "editions"){ + return {}; + } if (!criteria) { - return `${gridUrl}?cropType=portrait,landscape`; + return { + cropType: "portrait,landscape" + }; } // assumes the only criteria that will be passed as props the defined // constants for portrait(4:5), landscape (5:3) and landscape (5:4) - if (this.compareAspectRatio(portraitCardImageCriteria, criteria)) - return `${gridUrl}?cropType=portrait`; - else if (this.compareAspectRatio(landscape5To4CardImageCriteria, criteria)) - return `${gridUrl}?cropType=Landscape&customRatio=Landscape,5,4`; - else return `${gridUrl}?cropType=landscape`; + if (this.compareAspectRatio(portraitCardImageCriteria, criteria)) { + return { + cropType: "portrait" + }; + } + else if (this.compareAspectRatio(landscape5To4CardImageCriteria, criteria)) { + return { + cropType: "Landscape", + customRatio: "Landscape,5,4" + }; + } + else { + return { + cropType: "landscape" + }; + } }; private getCurrentImageDimensions = () => { From 8dc89eb644e90dfe54f65cad7936185a06848bde Mon Sep 17 00:00:00 2001 From: Andy Gallagher Date: Tue, 24 Sep 2024 12:53:27 +0100 Subject: [PATCH 15/15] Fix for https://the-guardian.sentry.io/issues/5820707430/?project=35467&referrer=issue-stream&statsPeriod=90d&stream_index=0&utc=true --- fronts-client/src/components/feed/RecipeSearchContainer.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fronts-client/src/components/feed/RecipeSearchContainer.tsx b/fronts-client/src/components/feed/RecipeSearchContainer.tsx index d63c84b0248..91478344959 100644 --- a/fronts-client/src/components/feed/RecipeSearchContainer.tsx +++ b/fronts-client/src/components/feed/RecipeSearchContainer.tsx @@ -117,7 +117,9 @@ export const RecipeSearchContainer = ({ rightHandContainer }: Props) => { )); case FeedType.chefs: - return chefSearchIds.map((chefId) => ( + //Fixing https://the-guardian.sentry.io/issues/5820707430/?project=35467&referrer=issue-stream&statsPeriod=90d&stream_index=0&utc=true + //It seems that some null values got into the `chefSearchIds` list + return chefSearchIds.filter(chefId=>!!chefId).map((chefId) => ( )); }