From d49b634ba450ae566607697b6ff3a7b5bac74a3d Mon Sep 17 00:00:00 2001 From: Andy Gallagher Date: Thu, 3 Oct 2024 17:56:30 +0100 Subject: [PATCH 1/6] Adding some UI elements to play with date-ordering functionality --- .../src/components/feed/RecipeFeedItem.tsx | 66 ++-- .../components/feed/RecipeSearchContainer.tsx | 337 +++++++++++------ fronts-client/src/services/recipeQuery.ts | 358 +++++++++--------- fronts-client/src/types/Recipe.ts | 15 +- 4 files changed, 463 insertions(+), 313 deletions(-) diff --git a/fronts-client/src/components/feed/RecipeFeedItem.tsx b/fronts-client/src/components/feed/RecipeFeedItem.tsx index ab6045b93ec..26dedbb219c 100644 --- a/fronts-client/src/components/feed/RecipeFeedItem.tsx +++ b/fronts-client/src/components/feed/RecipeFeedItem.tsx @@ -33,28 +33,46 @@ export const RecipeFeedItem = ({ id }: ComponentProps) => { ); }, [recipe]); - return ( - - Recipe - - {recipe?.score && recipe.score < 1 - ? `Relevance ${Math.ceil(recipe.score * 100)}%` - : ''} - - - } - /> - ); + const shortenTimestamp = (iso: string) => { + const parts = iso.split('T'); + return parts[0]; + }; + + return ( + + Recipe + + {recipe?.score && recipe.score < 1 + ? `Relevance ${Math.ceil(recipe.score * 100)}%` + : ''} +
+ {recipe?.lastModifiedDate + ? `M ${shortenTimestamp(recipe.lastModifiedDate)}` + : undefined} +
+ {recipe?.publishedDate + ? `P ${shortenTimestamp(recipe.publishedDate)}` + : undefined} +
+ {recipe?.firstPublishedDate + ? `F ${shortenTimestamp(recipe.firstPublishedDate)}` + : undefined} +
+
+ + } + /> + ); }; diff --git a/fronts-client/src/components/feed/RecipeSearchContainer.tsx b/fronts-client/src/components/feed/RecipeSearchContainer.tsx index 4dd892b7d57..4646a359ca1 100644 --- a/fronts-client/src/components/feed/RecipeSearchContainer.tsx +++ b/fronts-client/src/components/feed/RecipeSearchContainer.tsx @@ -1,7 +1,7 @@ import ClipboardHeader from 'components/ClipboardHeader'; import TextInput from 'components/inputs/TextInput'; import { styled } from 'constants/theme'; -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { fetchRecipes, @@ -16,11 +16,10 @@ import { Dispatch } from 'types/Store'; import { IPagination } from 'lib/createAsyncResourceBundle'; import Pagination from './Pagination'; import ScrollContainer from '../ScrollContainer'; -import { - ChefSearchParams, - RecipeSearchParams, -} from '../../services/recipeQuery'; +import { ChefSearchParams, DateParamField, RecipeSearchParams } from '../../services/recipeQuery'; import debounce from 'lodash/debounce'; +import { isNaN } from 'lodash'; +import ButtonDefault from '../inputs/ButtonDefault'; const InputContainer = styled.div` margin-bottom: 10px; @@ -59,24 +58,33 @@ enum FeedType { } export const RecipeSearchContainer = ({ rightHandContainer }: Props) => { - const [selectedOption, setSelectedOption] = useState(FeedType.recipes); - const [searchText, setSearchText] = useState(''); - const dispatch: Dispatch = useDispatch(); - const searchForChefs = useCallback( - (params: ChefSearchParams) => { - dispatch(fetchChefs(params)); - }, - [dispatch], - ); - const searchForRecipes = useCallback( - (params: RecipeSearchParams) => { - dispatch(fetchRecipes(params)); - }, - [dispatch], - ); - const recipeSearchIds = useSelector((state: State) => - recipeSelectors.selectLastFetchOrder(state), - ); + const [selectedOption, setSelectedOption] = useState(FeedType.recipes); + const [searchText, setSearchText] = useState(''); + + const [showAdvancedRecipes, setShowAdvancedRecipes] = useState(false); + const [dateField, setDateField] = useState(undefined); + const [uprateDropoffScale, setUprateDropoffScale] = useState< + number | undefined + >(90); + const [uprateOffsetDays, setUprateOffsetDays] = useState(7); + const [uprateDecay, setUprateDecay] = useState(0.95); + + const dispatch: Dispatch = useDispatch(); + const searchForChefs = useCallback( + (params: ChefSearchParams) => { + dispatch(fetchChefs(params)); + }, + [dispatch] + ); + const searchForRecipes = useCallback( + (params: RecipeSearchParams) => { + dispatch(fetchRecipes(params)); + }, + [dispatch] + ); + const recipeSearchIds = useSelector((state: State) => + recipeSelectors.selectLastFetchOrder(state) + ); const chefSearchIds = useSelector((state: State) => chefSelectors.selectLastFetchOrder(state), @@ -86,11 +94,19 @@ export const RecipeSearchContainer = ({ rightHandContainer }: Props) => { /*const debouncedRunSearch = debounce(() => runSearch(page), 750); TODO need to check if needed for chef-search? if yes then how to improve implementing it*/ - useEffect(() => { - const dbf = debounce(() => runSearch(page), 750); - dbf(); - return () => dbf.cancel(); - }, [selectedOption, searchText, page]); + useEffect(() => { + const dbf = debounce(() => runSearch(page), 750); + dbf(); + return () => dbf.cancel(); + }, [ + selectedOption, + searchText, + page, + dateField, + uprateDecay, + uprateDropoffScale, + uprateOffsetDays + ]); const chefsPagination: IPagination | null = useSelector((state: State) => chefSelectors.selectPagination(state), @@ -98,89 +114,186 @@ export const RecipeSearchContainer = ({ rightHandContainer }: Props) => { const hasPages = (chefsPagination?.totalPages ?? 0) > 1; - const runSearch = useCallback( - (page: number = 1) => { - switch (selectedOption) { - case FeedType.chefs: - searchForChefs({ - query: searchText, - }); - break; - case FeedType.recipes: - searchForRecipes({ - queryText: searchText, - }); - break; - } - }, - [selectedOption, searchText, page], - ); + const runSearch = useCallback( + (page: number = 1) => { + switch (selectedOption) { + case FeedType.chefs: + searchForChefs({ + query: searchText + }); + break; + case FeedType.recipes: + searchForRecipes({ + queryText: searchText, + uprateByDate: dateField, + uprateConfig: { + decay: uprateDecay, + dropoffScaleDays: uprateDropoffScale, + offsetDays: uprateOffsetDays + } + }); + break; + } + }, + [ + selectedOption, + searchText, + page, + dateField, + uprateDecay, + uprateDropoffScale, + uprateOffsetDays + ] + ); - const renderTheFeed = () => { - switch (selectedOption) { - case FeedType.recipes: - return recipeSearchIds.map((id) => ); - case FeedType.chefs: - //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) => ); - } - }; - - return ( - - - - { - setPage(1); - setSearchText(event.target.value); - }} - value={searchText} - /> - - - - - - setSelectedOption(FeedType.recipes)} - label="Recipes" - inline - name="recipeFeed" - /> - setSelectedOption(FeedType.chefs)} - label="Chefs" - inline - name="chefFeed" - /> - - {selectedOption === FeedType.chefs && chefsPagination && hasPages && ( - - setPage(page)} - currentPage={chefsPagination.currentPage} - totalPages={chefsPagination.totalPages} - /> - - )} - - - Results}> - {renderTheFeed()} - - - - ); + const renderTheFeed = () => { + switch (selectedOption) { + case FeedType.recipes: + return recipeSearchIds.map((id) => ( + + )); + case FeedType.chefs: + //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) => ( + + )); + } + }; + + return ( + + + + { + setPage(1); + setSearchText(event.target.value); + }} + onClick={() => setShowAdvancedRecipes(true)} + value={searchText} + /> + + + + + {showAdvancedRecipes ? ( + <> + +
+
+ { + const newVal = parseInt(evt.target.value); + if (!isNaN(newVal)) setUprateDropoffScale(newVal); + }} + /> +
+
+ { + const newVal = parseInt(evt.target.value); + if (!isNaN(newVal)) setUprateOffsetDays(newVal); + }} + /> +
+
+ { + const newVal = parseFloat(evt.target.value); + if (!isNaN(newVal)) setUprateDecay(newVal); + }} + /> +
+
+
+ +
+ + +
+
+ + setShowAdvancedRecipes(false)}>Close + + + ) : undefined} + + + + setSelectedOption(FeedType.recipes)} + label="Recipes" + inline + name="recipeFeed" + /> + setSelectedOption(FeedType.chefs)} + label="Chefs" + inline + name="chefFeed" + /> + + {selectedOption === FeedType.chefs && chefsPagination && hasPages && ( + + setPage(page)} + currentPage={chefsPagination.currentPage} + totalPages={chefsPagination.totalPages} + /> + + )} + + + Results}> + {renderTheFeed()} + + +
+ ); }; diff --git a/fronts-client/src/services/recipeQuery.ts b/fronts-client/src/services/recipeQuery.ts index 40ba9c3445e..eff8f302d5c 100644 --- a/fronts-client/src/services/recipeQuery.ts +++ b/fronts-client/src/services/recipeQuery.ts @@ -12,24 +12,40 @@ export interface ChefSearchParams { } export interface RecipeSearchFilters { - diets?: string[]; - contributors?: string[]; - filterType: 'During' | 'Post'; + diets?: string[]; + contributors?: string[]; + filterType: 'During' | 'Post'; } +export type DateParamField = + | undefined + | 'publishedDate' + | 'firstPublishedDate' + | 'lastModifiedDate'; + export interface RecipeSearchParams { - queryText: string; - searchType?: 'Embedded' | 'Match' | 'Lucene'; - fields?: string[]; - kfactor?: number; - limit?: number; - filters?: RecipeSearchFilters; + queryText: string; + searchType?: 'Embedded' | 'Match' | 'Lucene'; + fields?: string[]; + kfactor?: number; + limit?: number; + filters?: RecipeSearchFilters; + uprateByDate?: DateParamField; + uprateConfig?: { + originDate?: string; //should be ISO format date, defaults to today + //take this and add it to `offsetDays`. Then, weights will be modified so that + //at originDate +/- this many days results will be downweighted by `decay` + dropoffScaleDays?: number; + offsetDays?: number; + decay?: number; + }; + format?: 'Full' | 'Titles'; } export interface ChefSearchHit { - contributorType: 'Profile' | 'Byline'; - nameOrId: string; - docCount: number; + contributorType: 'Profile' | 'Byline'; + nameOrId: string; + docCount: number; } export interface ChefSearchResponse { @@ -49,180 +65,180 @@ interface RecipeSearchTitlesResponse { } export type RecipeSearchHit = Recipe & { - score: number; + score: number; }; export interface DietSearchResponse { - 'diet-ids': KeyAndCount[]; + 'diet-ids': KeyAndCount[]; } const widthParam = /width=(\d+)/; export const updateImageScalingParams = (url: string) => { - return url.replace(widthParam, 'width=83'); + return url.replace(widthParam, 'width=83'); }; const setupRecipeThumbnails = (recep: Recipe) => { - try { - return { - ...recep, - previewImage: recep.previewImage - ? { - ...recep.previewImage, - url: updateImageScalingParams(recep.previewImage.url), - } - : undefined, - featuredImage: recep.featuredImage - ? { - ...recep.featuredImage, - url: updateImageScalingParams(recep.featuredImage.url), - } - : undefined, - }; - } catch (err) { - console.error(err); - return recep; - } + try { + return { + ...recep, + previewImage: recep.previewImage + ? { + ...recep.previewImage, + url: updateImageScalingParams(recep.previewImage.url), + } + : undefined, + featuredImage: recep.featuredImage + ? { + ...recep.featuredImage, + url: updateImageScalingParams(recep.featuredImage.url), + } + : undefined, + }; + } catch (err) { + console.error(err); + return recep; + } }; const recipeQuery = (baseUrl: string) => { - const fetchOne = async (href: string): Promise => { - const response = await fetch(`${baseUrl}${href}`); - - switch (response.status) { - case 200: - const content = await response.json(); - return setupRecipeThumbnails(content as unknown as Recipe); - case 404: - case 403: - console.warn( - `Search response returned outdated recipe ${baseUrl}${href}`, - ); - return undefined; - default: - console.error(`Could not retrieve recipe ${href}: ${response.status}`); - return undefined; - } - }; - - const fetchAllRecipes = async ( - forRecipes: RecipeSearchTitlesResponse[], - ): Promise => { - const results = await Promise.all( - forRecipes.map((r) => - fetchOne(r.href) - .then((recep) => - recep - ? { - ...recep, - score: r.score, - } - : undefined, - ) - .catch(console.warn), - ), - ); - - return results.filter((r) => !!r) as RecipeSearchHit[]; - }; - - return { - chefs: async (params: ChefSearchParams): Promise => { - const args = [ - params.query ? `q=${encodeURIComponent(params.query)}` : undefined, - params.limit ? `limit=${encodeURIComponent(params.limit)}` : undefined, - ].filter((arg) => !!arg); - - const queryString = args.length > 0 ? '?' + args.join('&') : ''; - const url = `${baseUrl}/keywords/contributors${queryString}`; - const response = await fetch(url); - const content = await response.json(); - if (response.status == 200) { - return content as ChefSearchResponse; - } else { - console.error(content); - throw new Error(`Unable to contact recipe API: ${response.status}`); - } - }, - diets: async (): Promise => { - const response = await fetch(`${baseUrl}/keywords/diet-ids`); - const content = await response.json(); - if (response.status == 200) { - return content as DietSearchResponse; - } else { - console.error(content); - throw new Error(`Unable to contact recipe API: ${response.status}`); - } - }, - recipes: async ( - params: RecipeSearchParams, - ): Promise => { - const queryDoc = JSON.stringify(params); - const response = await fetch(`${baseUrl}/search`, { - method: 'POST', - body: queryDoc, - mode: 'cors', - headers: new Headers({ 'Content-Type': 'application/json' }), - }); - const content = await response.json(); - if (response.status == 200) { - const recipes = await fetchAllRecipes(content.results); - return { - hits: content.hits, - maxScore: content.maxScore, - recipes, - }; - } else { - console.error(content); - throw new Error(`Unable to contact recipe API: ${response.status}`); - } - }, - recipesById: async (idList: string[]): Promise => { - 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); - } - }; - - return recurseTheList(idList, []); - }, - }; + const fetchOne = async (href: string): Promise => { + const response = await fetch(`${baseUrl}${href}`); + + switch (response.status) { + case 200: + const content = await response.json(); + return setupRecipeThumbnails(content as unknown as Recipe); + case 404: + case 403: + console.warn( + `Search response returned outdated recipe ${baseUrl}${href}` + ); + return undefined; + default: + console.error(`Could not retrieve recipe ${href}: ${response.status}`); + return undefined; + } + }; + + const fetchAllRecipes = async ( + forRecipes: RecipeSearchTitlesResponse[] + ): Promise => { + const results = await Promise.all( + forRecipes.map((r) => + fetchOne(r.href) + .then((recep) => + recep + ? { + ...recep, + score: r.score, + } + : undefined + ) + .catch(console.warn) + ) + ); + + return results.filter((r) => !!r) as RecipeSearchHit[]; + }; + + return { + chefs: async (params: ChefSearchParams): Promise => { + const args = [ + params.query ? `q=${encodeURIComponent(params.query)}` : undefined, + params.limit ? `limit=${encodeURIComponent(params.limit)}` : undefined, + ].filter((arg) => !!arg); + + const queryString = args.length > 0 ? '?' + args.join('&') : ''; + const url = `${baseUrl}/keywords/contributors${queryString}`; + const response = await fetch(url); + const content = await response.json(); + if (response.status == 200) { + return content as ChefSearchResponse; + } else { + console.error(content); + throw new Error(`Unable to contact recipe API: ${response.status}`); + } + }, + diets: async (): Promise => { + const response = await fetch(`${baseUrl}/keywords/diet-ids`); + const content = await response.json(); + if (response.status == 200) { + return content as DietSearchResponse; + } else { + console.error(content); + throw new Error(`Unable to contact recipe API: ${response.status}`); + } + }, + recipes: async ( + params: RecipeSearchParams + ): Promise => { + const queryDoc = JSON.stringify(params); + const response = await fetch(`${baseUrl}/search`, { + method: 'POST', + body: queryDoc, + mode: 'cors', + headers: new Headers({ 'Content-Type': 'application/json' }), + }); + const content = await response.json(); + if (response.status == 200) { + const recipes = await fetchAllRecipes(content.results); + return { + hits: content.hits, + maxScore: content.maxScore, + recipes, + }; + } else { + console.error(content); + throw new Error(`Unable to contact recipe API: ${response.status}`); + } + }, + recipesById: async (idList: string[]): Promise => { + 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); + } + }; + + return recurseTheList(idList, []); + }, + }; }; const isCode = () => - window.location.hostname.includes('code.') || - window.location.hostname.includes('local.'); + window.location.hostname.includes('code.') || + window.location.hostname.includes('local.'); export const liveRecipes = recipeQuery( - isCode() ? url.codeRecipes : url.recipes, + isCode() ? url.codeRecipes : url.recipes ); diff --git a/fronts-client/src/types/Recipe.ts b/fronts-client/src/types/Recipe.ts index 19c53c5432f..9864aa2bc61 100644 --- a/fronts-client/src/types/Recipe.ts +++ b/fronts-client/src/types/Recipe.ts @@ -15,12 +15,15 @@ export interface RecipeImage { // Incomplete – add as we need more properties. Eventually, this would // be useful to derive from a package. export interface Recipe { - id: string; - title: string; - canonicalArticle: string; - difficultyLevel: string; - featuredImage?: RecipeImage; // the latter is an old image format that appears in our test fixtures - previewImage?: RecipeImage; + id: string; + title: string; + canonicalArticle: string; + difficultyLevel: string; + featuredImage?: RecipeImage; // the latter is an old image format that appears in our test fixtures + previewImage?: RecipeImage; + firstPublishedDate?: string; + lastModifiedDate?: string; + publishedDate?: string; } export interface RecipeIndexData { From c56d0663acd640145946987db3bfa3197697a69e Mon Sep 17 00:00:00 2001 From: Andy Gallagher Date: Fri, 18 Oct 2024 12:49:33 +0100 Subject: [PATCH 2/6] re-lint added bits --- .../src/components/feed/RecipeFeedItem.tsx | 82 ++-- .../components/feed/RecipeSearchContainer.tsx | 438 +++++++++--------- fronts-client/src/services/recipeQuery.ts | 370 +++++++-------- fronts-client/src/types/Recipe.ts | 18 +- 4 files changed, 456 insertions(+), 452 deletions(-) diff --git a/fronts-client/src/components/feed/RecipeFeedItem.tsx b/fronts-client/src/components/feed/RecipeFeedItem.tsx index 26dedbb219c..ebe37155ecb 100644 --- a/fronts-client/src/components/feed/RecipeFeedItem.tsx +++ b/fronts-client/src/components/feed/RecipeFeedItem.tsx @@ -33,46 +33,46 @@ export const RecipeFeedItem = ({ id }: ComponentProps) => { ); }, [recipe]); - const shortenTimestamp = (iso: string) => { - const parts = iso.split('T'); - return parts[0]; - }; + const shortenTimestamp = (iso: string) => { + const parts = iso.split('T'); + return parts[0]; + }; - return ( - - Recipe - - {recipe?.score && recipe.score < 1 - ? `Relevance ${Math.ceil(recipe.score * 100)}%` - : ''} -
- {recipe?.lastModifiedDate - ? `M ${shortenTimestamp(recipe.lastModifiedDate)}` - : undefined} -
- {recipe?.publishedDate - ? `P ${shortenTimestamp(recipe.publishedDate)}` - : undefined} -
- {recipe?.firstPublishedDate - ? `F ${shortenTimestamp(recipe.firstPublishedDate)}` - : undefined} -
-
- - } - /> - ); + return ( + + Recipe + + {recipe?.score && recipe.score < 1 + ? `Relevance ${Math.ceil(recipe.score * 100)}%` + : ''} +
+ {recipe?.lastModifiedDate + ? `M ${shortenTimestamp(recipe.lastModifiedDate)}` + : undefined} +
+ {recipe?.publishedDate + ? `P ${shortenTimestamp(recipe.publishedDate)}` + : undefined} +
+ {recipe?.firstPublishedDate + ? `F ${shortenTimestamp(recipe.firstPublishedDate)}` + : undefined} +
+
+ + } + /> + ); }; diff --git a/fronts-client/src/components/feed/RecipeSearchContainer.tsx b/fronts-client/src/components/feed/RecipeSearchContainer.tsx index 4646a359ca1..5dd8235c10f 100644 --- a/fronts-client/src/components/feed/RecipeSearchContainer.tsx +++ b/fronts-client/src/components/feed/RecipeSearchContainer.tsx @@ -16,7 +16,11 @@ import { Dispatch } from 'types/Store'; import { IPagination } from 'lib/createAsyncResourceBundle'; import Pagination from './Pagination'; import ScrollContainer from '../ScrollContainer'; -import { ChefSearchParams, DateParamField, RecipeSearchParams } from '../../services/recipeQuery'; +import { + ChefSearchParams, + DateParamField, + RecipeSearchParams, +} from '../../services/recipeQuery'; import debounce from 'lodash/debounce'; import { isNaN } from 'lodash'; import ButtonDefault from '../inputs/ButtonDefault'; @@ -58,33 +62,33 @@ enum FeedType { } export const RecipeSearchContainer = ({ rightHandContainer }: Props) => { - const [selectedOption, setSelectedOption] = useState(FeedType.recipes); - const [searchText, setSearchText] = useState(''); + const [selectedOption, setSelectedOption] = useState(FeedType.recipes); + const [searchText, setSearchText] = useState(''); - const [showAdvancedRecipes, setShowAdvancedRecipes] = useState(false); - const [dateField, setDateField] = useState(undefined); - const [uprateDropoffScale, setUprateDropoffScale] = useState< - number | undefined - >(90); - const [uprateOffsetDays, setUprateOffsetDays] = useState(7); - const [uprateDecay, setUprateDecay] = useState(0.95); + const [showAdvancedRecipes, setShowAdvancedRecipes] = useState(false); + const [dateField, setDateField] = useState(undefined); + const [uprateDropoffScale, setUprateDropoffScale] = useState< + number | undefined + >(90); + const [uprateOffsetDays, setUprateOffsetDays] = useState(7); + const [uprateDecay, setUprateDecay] = useState(0.95); - const dispatch: Dispatch = useDispatch(); - const searchForChefs = useCallback( - (params: ChefSearchParams) => { - dispatch(fetchChefs(params)); - }, - [dispatch] - ); - const searchForRecipes = useCallback( - (params: RecipeSearchParams) => { - dispatch(fetchRecipes(params)); - }, - [dispatch] - ); - const recipeSearchIds = useSelector((state: State) => - recipeSelectors.selectLastFetchOrder(state) - ); + const dispatch: Dispatch = useDispatch(); + const searchForChefs = useCallback( + (params: ChefSearchParams) => { + dispatch(fetchChefs(params)); + }, + [dispatch], + ); + const searchForRecipes = useCallback( + (params: RecipeSearchParams) => { + dispatch(fetchRecipes(params)); + }, + [dispatch], + ); + const recipeSearchIds = useSelector((state: State) => + recipeSelectors.selectLastFetchOrder(state), + ); const chefSearchIds = useSelector((state: State) => chefSelectors.selectLastFetchOrder(state), @@ -94,19 +98,19 @@ export const RecipeSearchContainer = ({ rightHandContainer }: Props) => { /*const debouncedRunSearch = debounce(() => runSearch(page), 750); TODO need to check if needed for chef-search? if yes then how to improve implementing it*/ - useEffect(() => { - const dbf = debounce(() => runSearch(page), 750); - dbf(); - return () => dbf.cancel(); - }, [ - selectedOption, - searchText, - page, - dateField, - uprateDecay, - uprateDropoffScale, - uprateOffsetDays - ]); + useEffect(() => { + const dbf = debounce(() => runSearch(page), 750); + dbf(); + return () => dbf.cancel(); + }, [ + selectedOption, + searchText, + page, + dateField, + uprateDecay, + uprateDropoffScale, + uprateOffsetDays, + ]); const chefsPagination: IPagination | null = useSelector((state: State) => chefSelectors.selectPagination(state), @@ -114,186 +118,186 @@ export const RecipeSearchContainer = ({ rightHandContainer }: Props) => { const hasPages = (chefsPagination?.totalPages ?? 0) > 1; - const runSearch = useCallback( - (page: number = 1) => { - switch (selectedOption) { - case FeedType.chefs: - searchForChefs({ - query: searchText - }); - break; - case FeedType.recipes: - searchForRecipes({ - queryText: searchText, - uprateByDate: dateField, - uprateConfig: { - decay: uprateDecay, - dropoffScaleDays: uprateDropoffScale, - offsetDays: uprateOffsetDays - } - }); - break; - } - }, - [ - selectedOption, - searchText, - page, - dateField, - uprateDecay, - uprateDropoffScale, - uprateOffsetDays - ] - ); + const runSearch = useCallback( + (page: number = 1) => { + switch (selectedOption) { + case FeedType.chefs: + searchForChefs({ + query: searchText, + }); + break; + case FeedType.recipes: + searchForRecipes({ + queryText: searchText, + uprateByDate: dateField, + uprateConfig: { + decay: uprateDecay, + dropoffScaleDays: uprateDropoffScale, + offsetDays: uprateOffsetDays, + }, + }); + break; + } + }, + [ + selectedOption, + searchText, + page, + dateField, + uprateDecay, + uprateDropoffScale, + uprateOffsetDays, + ], + ); - const renderTheFeed = () => { - switch (selectedOption) { - case FeedType.recipes: - return recipeSearchIds.map((id) => ( - - )); - case FeedType.chefs: - //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) => ( - - )); - } - }; + const renderTheFeed = () => { + switch (selectedOption) { + case FeedType.recipes: + return recipeSearchIds.map((id) => ); + case FeedType.chefs: + //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) => ); + } + }; - return ( - - - - { - setPage(1); - setSearchText(event.target.value); - }} - onClick={() => setShowAdvancedRecipes(true)} - value={searchText} - /> - - - + return ( + + + + { + setPage(1); + setSearchText(event.target.value); + }} + onClick={() => setShowAdvancedRecipes(true)} + value={searchText} + /> + + + - {showAdvancedRecipes ? ( - <> - -
-
- { - const newVal = parseInt(evt.target.value); - if (!isNaN(newVal)) setUprateDropoffScale(newVal); - }} - /> -
-
- { - const newVal = parseInt(evt.target.value); - if (!isNaN(newVal)) setUprateOffsetDays(newVal); - }} - /> -
-
- { - const newVal = parseFloat(evt.target.value); - if (!isNaN(newVal)) setUprateDecay(newVal); - }} - /> -
-
-
- -
- - -
-
- - setShowAdvancedRecipes(false)}>Close - - - ) : undefined} + {showAdvancedRecipes ? ( + <> + +
+
+ { + const newVal = parseInt(evt.target.value); + if (!isNaN(newVal)) setUprateDropoffScale(newVal); + }} + /> +
+
+ { + const newVal = parseInt(evt.target.value); + if (!isNaN(newVal)) setUprateOffsetDays(newVal); + }} + /> +
+
+ { + const newVal = parseFloat(evt.target.value); + if (!isNaN(newVal)) setUprateDecay(newVal); + }} + /> +
+
+
+ +
+ + +
+
+ + setShowAdvancedRecipes(false)}> + Close + + + + ) : undefined} - - - setSelectedOption(FeedType.recipes)} - label="Recipes" - inline - name="recipeFeed" - /> - setSelectedOption(FeedType.chefs)} - label="Chefs" - inline - name="chefFeed" - /> - - {selectedOption === FeedType.chefs && chefsPagination && hasPages && ( - - setPage(page)} - currentPage={chefsPagination.currentPage} - totalPages={chefsPagination.totalPages} - /> - - )} - - - Results}> - {renderTheFeed()} - - -
- ); + + + setSelectedOption(FeedType.recipes)} + label="Recipes" + inline + name="recipeFeed" + /> + setSelectedOption(FeedType.chefs)} + label="Chefs" + inline + name="chefFeed" + /> + + {selectedOption === FeedType.chefs && chefsPagination && hasPages && ( + + setPage(page)} + currentPage={chefsPagination.currentPage} + totalPages={chefsPagination.totalPages} + /> + + )} + + + Results}> + {renderTheFeed()} + + +
+ ); }; diff --git a/fronts-client/src/services/recipeQuery.ts b/fronts-client/src/services/recipeQuery.ts index eff8f302d5c..f1c7c8d879c 100644 --- a/fronts-client/src/services/recipeQuery.ts +++ b/fronts-client/src/services/recipeQuery.ts @@ -12,40 +12,40 @@ export interface ChefSearchParams { } export interface RecipeSearchFilters { - diets?: string[]; - contributors?: string[]; - filterType: 'During' | 'Post'; + diets?: string[]; + contributors?: string[]; + filterType: 'During' | 'Post'; } export type DateParamField = - | undefined - | 'publishedDate' - | 'firstPublishedDate' - | 'lastModifiedDate'; + | undefined + | 'publishedDate' + | 'firstPublishedDate' + | 'lastModifiedDate'; export interface RecipeSearchParams { - queryText: string; - searchType?: 'Embedded' | 'Match' | 'Lucene'; - fields?: string[]; - kfactor?: number; - limit?: number; - filters?: RecipeSearchFilters; - uprateByDate?: DateParamField; - uprateConfig?: { - originDate?: string; //should be ISO format date, defaults to today - //take this and add it to `offsetDays`. Then, weights will be modified so that - //at originDate +/- this many days results will be downweighted by `decay` - dropoffScaleDays?: number; - offsetDays?: number; - decay?: number; - }; - format?: 'Full' | 'Titles'; + queryText: string; + searchType?: 'Embedded' | 'Match' | 'Lucene'; + fields?: string[]; + kfactor?: number; + limit?: number; + filters?: RecipeSearchFilters; + uprateByDate?: DateParamField; + uprateConfig?: { + originDate?: string; //should be ISO format date, defaults to today + //take this and add it to `offsetDays`. Then, weights will be modified so that + //at originDate +/- this many days results will be downweighted by `decay` + dropoffScaleDays?: number; + offsetDays?: number; + decay?: number; + }; + format?: 'Full' | 'Titles'; } export interface ChefSearchHit { - contributorType: 'Profile' | 'Byline'; - nameOrId: string; - docCount: number; + contributorType: 'Profile' | 'Byline'; + nameOrId: string; + docCount: number; } export interface ChefSearchResponse { @@ -65,180 +65,180 @@ interface RecipeSearchTitlesResponse { } export type RecipeSearchHit = Recipe & { - score: number; + score: number; }; export interface DietSearchResponse { - 'diet-ids': KeyAndCount[]; + 'diet-ids': KeyAndCount[]; } const widthParam = /width=(\d+)/; export const updateImageScalingParams = (url: string) => { - return url.replace(widthParam, 'width=83'); + return url.replace(widthParam, 'width=83'); }; const setupRecipeThumbnails = (recep: Recipe) => { - try { - return { - ...recep, - previewImage: recep.previewImage - ? { - ...recep.previewImage, - url: updateImageScalingParams(recep.previewImage.url), - } - : undefined, - featuredImage: recep.featuredImage - ? { - ...recep.featuredImage, - url: updateImageScalingParams(recep.featuredImage.url), - } - : undefined, - }; - } catch (err) { - console.error(err); - return recep; - } + try { + return { + ...recep, + previewImage: recep.previewImage + ? { + ...recep.previewImage, + url: updateImageScalingParams(recep.previewImage.url), + } + : undefined, + featuredImage: recep.featuredImage + ? { + ...recep.featuredImage, + url: updateImageScalingParams(recep.featuredImage.url), + } + : undefined, + }; + } catch (err) { + console.error(err); + return recep; + } }; const recipeQuery = (baseUrl: string) => { - const fetchOne = async (href: string): Promise => { - const response = await fetch(`${baseUrl}${href}`); - - switch (response.status) { - case 200: - const content = await response.json(); - return setupRecipeThumbnails(content as unknown as Recipe); - case 404: - case 403: - console.warn( - `Search response returned outdated recipe ${baseUrl}${href}` - ); - return undefined; - default: - console.error(`Could not retrieve recipe ${href}: ${response.status}`); - return undefined; - } - }; - - const fetchAllRecipes = async ( - forRecipes: RecipeSearchTitlesResponse[] - ): Promise => { - const results = await Promise.all( - forRecipes.map((r) => - fetchOne(r.href) - .then((recep) => - recep - ? { - ...recep, - score: r.score, - } - : undefined - ) - .catch(console.warn) - ) - ); - - return results.filter((r) => !!r) as RecipeSearchHit[]; - }; - - return { - chefs: async (params: ChefSearchParams): Promise => { - const args = [ - params.query ? `q=${encodeURIComponent(params.query)}` : undefined, - params.limit ? `limit=${encodeURIComponent(params.limit)}` : undefined, - ].filter((arg) => !!arg); - - const queryString = args.length > 0 ? '?' + args.join('&') : ''; - const url = `${baseUrl}/keywords/contributors${queryString}`; - const response = await fetch(url); - const content = await response.json(); - if (response.status == 200) { - return content as ChefSearchResponse; - } else { - console.error(content); - throw new Error(`Unable to contact recipe API: ${response.status}`); - } - }, - diets: async (): Promise => { - const response = await fetch(`${baseUrl}/keywords/diet-ids`); - const content = await response.json(); - if (response.status == 200) { - return content as DietSearchResponse; - } else { - console.error(content); - throw new Error(`Unable to contact recipe API: ${response.status}`); - } - }, - recipes: async ( - params: RecipeSearchParams - ): Promise => { - const queryDoc = JSON.stringify(params); - const response = await fetch(`${baseUrl}/search`, { - method: 'POST', - body: queryDoc, - mode: 'cors', - headers: new Headers({ 'Content-Type': 'application/json' }), - }); - const content = await response.json(); - if (response.status == 200) { - const recipes = await fetchAllRecipes(content.results); - return { - hits: content.hits, - maxScore: content.maxScore, - recipes, - }; - } else { - console.error(content); - throw new Error(`Unable to contact recipe API: ${response.status}`); - } - }, - recipesById: async (idList: string[]): Promise => { - 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); - } - }; - - return recurseTheList(idList, []); - }, - }; + const fetchOne = async (href: string): Promise => { + const response = await fetch(`${baseUrl}${href}`); + + switch (response.status) { + case 200: + const content = await response.json(); + return setupRecipeThumbnails(content as unknown as Recipe); + case 404: + case 403: + console.warn( + `Search response returned outdated recipe ${baseUrl}${href}`, + ); + return undefined; + default: + console.error(`Could not retrieve recipe ${href}: ${response.status}`); + return undefined; + } + }; + + const fetchAllRecipes = async ( + forRecipes: RecipeSearchTitlesResponse[], + ): Promise => { + const results = await Promise.all( + forRecipes.map((r) => + fetchOne(r.href) + .then((recep) => + recep + ? { + ...recep, + score: r.score, + } + : undefined, + ) + .catch(console.warn), + ), + ); + + return results.filter((r) => !!r) as RecipeSearchHit[]; + }; + + return { + chefs: async (params: ChefSearchParams): Promise => { + const args = [ + params.query ? `q=${encodeURIComponent(params.query)}` : undefined, + params.limit ? `limit=${encodeURIComponent(params.limit)}` : undefined, + ].filter((arg) => !!arg); + + const queryString = args.length > 0 ? '?' + args.join('&') : ''; + const url = `${baseUrl}/keywords/contributors${queryString}`; + const response = await fetch(url); + const content = await response.json(); + if (response.status == 200) { + return content as ChefSearchResponse; + } else { + console.error(content); + throw new Error(`Unable to contact recipe API: ${response.status}`); + } + }, + diets: async (): Promise => { + const response = await fetch(`${baseUrl}/keywords/diet-ids`); + const content = await response.json(); + if (response.status == 200) { + return content as DietSearchResponse; + } else { + console.error(content); + throw new Error(`Unable to contact recipe API: ${response.status}`); + } + }, + recipes: async ( + params: RecipeSearchParams, + ): Promise => { + const queryDoc = JSON.stringify(params); + const response = await fetch(`${baseUrl}/search`, { + method: 'POST', + body: queryDoc, + mode: 'cors', + headers: new Headers({ 'Content-Type': 'application/json' }), + }); + const content = await response.json(); + if (response.status == 200) { + const recipes = await fetchAllRecipes(content.results); + return { + hits: content.hits, + maxScore: content.maxScore, + recipes, + }; + } else { + console.error(content); + throw new Error(`Unable to contact recipe API: ${response.status}`); + } + }, + recipesById: async (idList: string[]): Promise => { + 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); + } + }; + + return recurseTheList(idList, []); + }, + }; }; const isCode = () => - window.location.hostname.includes('code.') || - window.location.hostname.includes('local.'); + window.location.hostname.includes('code.') || + window.location.hostname.includes('local.'); export const liveRecipes = recipeQuery( - isCode() ? url.codeRecipes : url.recipes + isCode() ? url.codeRecipes : url.recipes, ); diff --git a/fronts-client/src/types/Recipe.ts b/fronts-client/src/types/Recipe.ts index 9864aa2bc61..6d6945e9670 100644 --- a/fronts-client/src/types/Recipe.ts +++ b/fronts-client/src/types/Recipe.ts @@ -15,15 +15,15 @@ export interface RecipeImage { // Incomplete – add as we need more properties. Eventually, this would // be useful to derive from a package. export interface Recipe { - id: string; - title: string; - canonicalArticle: string; - difficultyLevel: string; - featuredImage?: RecipeImage; // the latter is an old image format that appears in our test fixtures - previewImage?: RecipeImage; - firstPublishedDate?: string; - lastModifiedDate?: string; - publishedDate?: string; + id: string; + title: string; + canonicalArticle: string; + difficultyLevel: string; + featuredImage?: RecipeImage; // the latter is an old image format that appears in our test fixtures + previewImage?: RecipeImage; + firstPublishedDate?: string; + lastModifiedDate?: string; + publishedDate?: string; } export interface RecipeIndexData { From 7d994ea1f66c4a1833e7312123e7a2ca65b16760 Mon Sep 17 00:00:00 2001 From: Andy Gallagher Date: Fri, 18 Oct 2024 14:05:15 +0100 Subject: [PATCH 3/6] tidy up the UI; add a "forcefulness" control that allows the user to prioritise either relevance or date --- .../components/feed/RecipeSearchContainer.tsx | 134 ++++++++---------- fronts-client/src/services/recipeQuery.ts | 5 +- 2 files changed, 62 insertions(+), 77 deletions(-) diff --git a/fronts-client/src/components/feed/RecipeSearchContainer.tsx b/fronts-client/src/components/feed/RecipeSearchContainer.tsx index 5dd8235c10f..eb757495b70 100644 --- a/fronts-client/src/components/feed/RecipeSearchContainer.tsx +++ b/fronts-client/src/components/feed/RecipeSearchContainer.tsx @@ -22,7 +22,6 @@ import { RecipeSearchParams, } from '../../services/recipeQuery'; import debounce from 'lodash/debounce'; -import { isNaN } from 'lodash'; import ButtonDefault from '../inputs/ButtonDefault'; const InputContainer = styled.div` @@ -46,6 +45,9 @@ const PaginationContainer = styled.div` const TopOptions = styled.div` display: flex; flex-direction: row; + justify-content: space-between; + margin-right: 1em; + margin-bottom: 1em; `; const FeedsContainerWrapper = styled.div` @@ -67,11 +69,7 @@ export const RecipeSearchContainer = ({ rightHandContainer }: Props) => { const [showAdvancedRecipes, setShowAdvancedRecipes] = useState(false); const [dateField, setDateField] = useState(undefined); - const [uprateDropoffScale, setUprateDropoffScale] = useState< - number | undefined - >(90); - const [uprateOffsetDays, setUprateOffsetDays] = useState(7); - const [uprateDecay, setUprateDecay] = useState(0.95); + const [orderingForce, setOrderingForce] = useState('default'); const dispatch: Dispatch = useDispatch(); const searchForChefs = useCallback( @@ -96,21 +94,11 @@ export const RecipeSearchContainer = ({ rightHandContainer }: Props) => { const [page, setPage] = useState(1); - /*const debouncedRunSearch = debounce(() => runSearch(page), 750); TODO need to check if needed for chef-search? if yes then how to improve implementing it*/ - useEffect(() => { const dbf = debounce(() => runSearch(page), 750); dbf(); return () => dbf.cancel(); - }, [ - selectedOption, - searchText, - page, - dateField, - uprateDecay, - uprateDropoffScale, - uprateOffsetDays, - ]); + }, [selectedOption, searchText, page, dateField, orderingForce]); const chefsPagination: IPagination | null = useSelector((state: State) => chefSelectors.selectPagination(state), @@ -118,6 +106,25 @@ export const RecipeSearchContainer = ({ rightHandContainer }: Props) => { const hasPages = (chefsPagination?.totalPages ?? 0) > 1; + const getUpdateConfig = () => { + switch (orderingForce) { + case 'default': + return undefined; + case 'gentle': + return { + decay: 0.95, + dropoffScaleDays: 90, + offsetDays: 7, + }; + case 'forceful': + return { + decay: 0.7, + dropoffScaleDays: 180, + offsetDays: 14, + }; + } + }; + const runSearch = useCallback( (page: number = 1) => { switch (selectedOption) { @@ -130,24 +137,12 @@ export const RecipeSearchContainer = ({ rightHandContainer }: Props) => { searchForRecipes({ queryText: searchText, uprateByDate: dateField, - uprateConfig: { - decay: uprateDecay, - dropoffScaleDays: uprateDropoffScale, - offsetDays: uprateOffsetDays, - }, + uprateConfig: getUpdateConfig(), }); break; } }, - [ - selectedOption, - searchText, - page, - dateField, - uprateDecay, - uprateDropoffScale, - uprateOffsetDays, - ], + [selectedOption, searchText, page, dateField, orderingForce], ); const renderTheFeed = () => { @@ -187,52 +182,11 @@ export const RecipeSearchContainer = ({ rightHandContainer }: Props) => { {showAdvancedRecipes ? ( <> - -
-
- { - const newVal = parseInt(evt.target.value); - if (!isNaN(newVal)) setUprateDropoffScale(newVal); - }} - /> -
-
- { - const newVal = parseInt(evt.target.value); - if (!isNaN(newVal)) setUprateOffsetDays(newVal); - }} - /> -
-
- { - const newVal = parseFloat(evt.target.value); - if (!isNaN(newVal)) setUprateDecay(newVal); - }} - /> -
-
-
-
+
+
+
- + +
+ +
+
+ +
+
+ setShowAdvancedRecipes(false)}> Close diff --git a/fronts-client/src/services/recipeQuery.ts b/fronts-client/src/services/recipeQuery.ts index f1c7c8d879c..a6fb7b6fefb 100644 --- a/fronts-client/src/services/recipeQuery.ts +++ b/fronts-client/src/services/recipeQuery.ts @@ -172,7 +172,10 @@ const recipeQuery = (baseUrl: string) => { recipes: async ( params: RecipeSearchParams, ): Promise => { - const queryDoc = JSON.stringify(params); + const queryDoc = JSON.stringify({ + ...params, + noStats: true, //we are not reading stats, so no point slowing the query down by retrieving them. + }); const response = await fetch(`${baseUrl}/search`, { method: 'POST', body: queryDoc, From 827cc0033e4147fbfdf4fb405820bbd28fd0a317 Mon Sep 17 00:00:00 2001 From: Andy Gallagher Date: Fri, 18 Oct 2024 14:31:35 +0100 Subject: [PATCH 4/6] Allow a JSX element into the generic FeedItem card. Nicer time display. Always on when ordering with date, option for the user to force it 'always-on'. --- .../src/components/feed/FeedItem.tsx | 19 +++++--- .../src/components/feed/RecipeFeedItem.tsx | 45 +++++++++++-------- .../components/feed/RecipeSearchContainer.tsx | 31 +++++++++++-- 3 files changed, 69 insertions(+), 26 deletions(-) diff --git a/fronts-client/src/components/feed/FeedItem.tsx b/fronts-client/src/components/feed/FeedItem.tsx index d1a8c1733f4..cf29aa3c161 100644 --- a/fronts-client/src/components/feed/FeedItem.tsx +++ b/fronts-client/src/components/feed/FeedItem.tsx @@ -53,7 +53,7 @@ const Byline = styled.h2` font-weight: bold; `; -const Title = styled.h2` +export const Title = styled.h2` margin: 2px 2px 0 0; vertical-align: top; font-family: TS3TextSans; @@ -111,6 +111,7 @@ interface FeedItemProps { id: string; type: CardTypes; title: string; + bodyContent?: JSX.Element; liveUrl?: string; metaContent?: JSX.Element; scheduledPublicationDate?: string; @@ -138,6 +139,7 @@ export class FeedItem extends React.Component { id, type, title, + bodyContent, liveUrl, isLive, metaContent, @@ -199,10 +201,17 @@ export class FeedItem extends React.Component { - {title} - {byline ? ( - {byline} - ) : undefined} + {bodyContent ? ( + bodyContent + ) : ( + <> + {title} + {byline ? ( + {byline} + ) : undefined} + ) + + )} { +export const RecipeFeedItem = ({ id, showTimes }: ComponentProps) => { const shouldObscureFeed = useSelector((state) => selectFeatureValue(state, 'obscure-feed'), ); @@ -33,11 +36,15 @@ export const RecipeFeedItem = ({ id }: ComponentProps) => { ); }, [recipe]); - const shortenTimestamp = (iso: string) => { - const parts = iso.split('T'); - return parts[0]; + const renderTimestamp = (iso: string) => { + try { + const date = new Date(iso); + return format(date, 'HH:mm on do MMM YYYY'); + } catch (err) { + console.warn(err); + return iso; + } }; - return ( { handleDragStart={handleDragStartForCard(CardTypesMap.RECIPE, recipe)} onAddToClipboard={onAddToClipboard} shouldObscureFeed={shouldObscureFeed} + bodyContent={ + <> + {recipe.title} + {recipe?.lastModifiedDate && showTimes ? ( + + Modified {renderTimestamp(recipe.lastModifiedDate)} + + ) : undefined} + {recipe?.publishedDate && showTimes ? ( + + First published {renderTimestamp(recipe.publishedDate)} + + ) : undefined} + + } metaContent={ <> Recipe @@ -57,19 +79,6 @@ export const RecipeFeedItem = ({ id }: ComponentProps) => { {recipe?.score && recipe.score < 1 ? `Relevance ${Math.ceil(recipe.score * 100)}%` : ''} -
- {recipe?.lastModifiedDate - ? `M ${shortenTimestamp(recipe.lastModifiedDate)}` - : undefined} -
- {recipe?.publishedDate - ? `P ${shortenTimestamp(recipe.publishedDate)}` - : undefined} -
- {recipe?.firstPublishedDate - ? `F ${shortenTimestamp(recipe.firstPublishedDate)}` - : undefined} -
} diff --git a/fronts-client/src/components/feed/RecipeSearchContainer.tsx b/fronts-client/src/components/feed/RecipeSearchContainer.tsx index eb757495b70..731c56827b7 100644 --- a/fronts-client/src/components/feed/RecipeSearchContainer.tsx +++ b/fronts-client/src/components/feed/RecipeSearchContainer.tsx @@ -70,6 +70,7 @@ export const RecipeSearchContainer = ({ rightHandContainer }: Props) => { const [showAdvancedRecipes, setShowAdvancedRecipes] = useState(false); const [dateField, setDateField] = useState(undefined); const [orderingForce, setOrderingForce] = useState('default'); + const [forceDates, setForceDates] = useState(false); const dispatch: Dispatch = useDispatch(); const searchForChefs = useCallback( @@ -148,7 +149,13 @@ export const RecipeSearchContainer = ({ rightHandContainer }: Props) => { const renderTheFeed = () => { switch (selectedOption) { case FeedType.recipes: - return recipeSearchIds.map((id) => ); + return recipeSearchIds.map((id) => ( + + )); case FeedType.chefs: //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 @@ -180,16 +187,22 @@ export const RecipeSearchContainer = ({ rightHandContainer }: Props) => { - {showAdvancedRecipes ? ( + {showAdvancedRecipes && selectedOption === FeedType.recipes ? ( <>
- +
+ +
+ +
+
+ setForceDates(evt.target.checked)} + /> +
+
Date: Fri, 18 Oct 2024 14:39:17 +0100 Subject: [PATCH 5/6] Fix typo, don't overwrite recipes feed when loading non-search data --- fronts-client/src/bundles/recipesBundle.ts | 2 +- fronts-client/src/components/feed/FeedItem.tsx | 1 - fronts-client/src/components/feed/RecipeFeedItem.tsx | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/fronts-client/src/bundles/recipesBundle.ts b/fronts-client/src/bundles/recipesBundle.ts index 034815c4bd5..37998dde4ec 100644 --- a/fronts-client/src/bundles/recipesBundle.ts +++ b/fronts-client/src/bundles/recipesBundle.ts @@ -43,7 +43,7 @@ export const fetchRecipesById = const recipes = await liveRecipes.recipesById(idList); dispatch( actions.fetchSuccess(recipes, { - order: recipes.map((_) => _.id), + ignoreOrder: true, }), ); } catch (e) { diff --git a/fronts-client/src/components/feed/FeedItem.tsx b/fronts-client/src/components/feed/FeedItem.tsx index cf29aa3c161..d3c19941723 100644 --- a/fronts-client/src/components/feed/FeedItem.tsx +++ b/fronts-client/src/components/feed/FeedItem.tsx @@ -209,7 +209,6 @@ export class FeedItem extends React.Component { {byline ? ( {byline} ) : undefined} - ) )} diff --git a/fronts-client/src/components/feed/RecipeFeedItem.tsx b/fronts-client/src/components/feed/RecipeFeedItem.tsx index d213b5e0a76..f4896de3ed0 100644 --- a/fronts-client/src/components/feed/RecipeFeedItem.tsx +++ b/fronts-client/src/components/feed/RecipeFeedItem.tsx @@ -67,7 +67,7 @@ export const RecipeFeedItem = ({ id, showTimes }: ComponentProps) => { ) : undefined} {recipe?.publishedDate && showTimes ? ( - First published {renderTimestamp(recipe.publishedDate)} + Published {renderTimestamp(recipe.publishedDate)} ) : undefined} From 45377a0be0b25719307f27fdfcc9a5814cef0312 Mon Sep 17 00:00:00 2001 From: Andy Gallagher Date: Fri, 18 Oct 2024 20:51:45 +0100 Subject: [PATCH 6/6] hide pinboard buttons for recipes --- fronts-client/src/components/feed/FeedItem.tsx | 3 +++ fronts-client/src/components/feed/RecipeFeedItem.tsx | 1 + .../src/components/inputs/HoverActionButtonWrapper.tsx | 4 +++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/fronts-client/src/components/feed/FeedItem.tsx b/fronts-client/src/components/feed/FeedItem.tsx index d3c19941723..3371851bf5f 100644 --- a/fronts-client/src/components/feed/FeedItem.tsx +++ b/fronts-client/src/components/feed/FeedItem.tsx @@ -126,6 +126,7 @@ interface FeedItemProps { ) => void; shouldObscureFeed?: boolean; byline?: string; + noPinboard?: boolean; } export class FeedItem extends React.Component { @@ -151,6 +152,7 @@ export class FeedItem extends React.Component { hasVideo, handleDragStart, byline, + noPinboard, } = this.props; const { preview, live, ophan } = getPaths(id); @@ -229,6 +231,7 @@ export class FeedItem extends React.Component { toolTipPosition={'top'} toolTipAlign={'right'} urlPath={liveUrl} + noPinboard={noPinboard} renderButtons={(props) => ( <> diff --git a/fronts-client/src/components/feed/RecipeFeedItem.tsx b/fronts-client/src/components/feed/RecipeFeedItem.tsx index f4896de3ed0..6a925f3e243 100644 --- a/fronts-client/src/components/feed/RecipeFeedItem.tsx +++ b/fronts-client/src/components/feed/RecipeFeedItem.tsx @@ -57,6 +57,7 @@ export const RecipeFeedItem = ({ id, showTimes }: ComponentProps) => { handleDragStart={handleDragStartForCard(CardTypesMap.RECIPE, recipe)} onAddToClipboard={onAddToClipboard} shouldObscureFeed={shouldObscureFeed} + noPinboard={true} bodyContent={ <> {recipe.title} diff --git a/fronts-client/src/components/inputs/HoverActionButtonWrapper.tsx b/fronts-client/src/components/inputs/HoverActionButtonWrapper.tsx index bcd51fd5e6e..a3b02a60a77 100644 --- a/fronts-client/src/components/inputs/HoverActionButtonWrapper.tsx +++ b/fronts-client/src/components/inputs/HoverActionButtonWrapper.tsx @@ -45,6 +45,7 @@ interface WrapperProps { toolTipAlign: 'left' | 'center' | 'right'; urlPath: string | undefined; renderButtons: (renderProps: ButtonProps) => JSX.Element; + noPinboard?: boolean; } export const HoverActionsButtonWrapper = ({ @@ -53,6 +54,7 @@ export const HoverActionsButtonWrapper = ({ size, urlPath, renderButtons, + noPinboard, }: WrapperProps) => { const [toolTipText, setToolTipText] = useState(undefined); @@ -79,7 +81,7 @@ export const HoverActionsButtonWrapper = ({ hideToolTip, size, })} - {urlPath && ( + {urlPath && !noPinboard && ( // the below tag is empty and meaningless to the fronts tool itself, but serves as a handle for // Pinboard to attach itself via, identified/distinguished by the urlPath data attribute // @ts-ignore