From 3fad1a0036d9b06249be36d55cf40344ec3bed0b Mon Sep 17 00:00:00 2001 From: Samir Benzenine Date: Thu, 23 Jan 2025 11:55:48 +0000 Subject: [PATCH] Newswires UI: add pagination --- newswires/client/src/Feed.tsx | 4 +- newswires/client/src/WireItemTable.tsx | 69 ++++++++++++------- .../client/src/context/SearchContext.tsx | 42 ++++++++++- .../client/src/context/SearchReducer.test.ts | 43 ++++++++++++ newswires/client/src/context/SearchReducer.ts | 37 ++++++++++ newswires/client/src/context/fetchResults.ts | 6 +- newswires/client/src/urlState.ts | 8 ++- 7 files changed, 175 insertions(+), 34 deletions(-) diff --git a/newswires/client/src/Feed.tsx b/newswires/client/src/Feed.tsx index 6a45fa81..44bbe7c9 100644 --- a/newswires/client/src/Feed.tsx +++ b/newswires/client/src/Feed.tsx @@ -25,7 +25,9 @@ export const Feed = () => { titleSize="s" /> )} - {(status == 'success' || status == 'offline') && + {(status == 'success' || + status == 'offline' || + status == 'loading-more') && queryData.results.length > 0 && ( )} diff --git a/newswires/client/src/WireItemTable.tsx b/newswires/client/src/WireItemTable.tsx index a56d1cc9..94720230 100644 --- a/newswires/client/src/WireItemTable.tsx +++ b/newswires/client/src/WireItemTable.tsx @@ -1,4 +1,5 @@ import { + EuiButton, EuiFlexGroup, euiScreenReaderOnly, EuiTable, @@ -13,10 +14,12 @@ import { useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; +import { useEffect, useState } from 'react'; import sanitizeHtml from 'sanitize-html'; import { useSearch } from './context/SearchContext.tsx'; import { formatTimestamp } from './formatTimestamp'; import type { WireData } from './sharedTypes'; +import { configToUrl } from './urlState.ts'; const fadeOutBackground = css` animation: fadeOut ease-out 15s; @@ -31,38 +34,54 @@ const fadeOutBackground = css` `; export const WireItemTable = ({ wires }: { wires: WireData[] }) => { - const { config, handleSelectItem } = useSearch(); + const { + config, + handleSelectItem, + state: { status }, + loadMoreResults, + } = useSearch(); const selectedWireId = config.itemId; return ( - - + + + Headline + Version Created + + + {wires.map(({ id, supplier, content, isFromRefresh, highlight }) => ( + + ))} + + + - Headline - Version Created - - - {wires.map(({ id, supplier, content, isFromRefresh, highlight }) => ( - - ))} - - + {status === 'loading-more' ? 'Loading' : 'Load more'} + + ); }; diff --git a/newswires/client/src/context/SearchContext.tsx b/newswires/client/src/context/SearchContext.tsx index 9ee4116c..c6851713 100644 --- a/newswires/client/src/context/SearchContext.tsx +++ b/newswires/client/src/context/SearchContext.tsx @@ -43,6 +43,13 @@ const StateSchema = z.discriminatedUnion('status', [ successfulQueryHistory: SearchHistorySchema, autoUpdate: z.boolean().default(true), }), + z.object({ + status: z.literal('loading-more'), + error: z.string().optional(), + queryData: WiresQueryResponseSchema, + successfulQueryHistory: SearchHistorySchema, + autoUpdate: z.boolean().default(true), + }), z.object({ status: z.literal('success'), error: z.string().optional(), @@ -72,11 +79,16 @@ export type State = z.infer; // Action Schema const ActionSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('ENTER_QUERY') }), + z.object({ type: z.literal('LOAD_MORE_RESULTS') }), z.object({ type: z.literal('FETCH_SUCCESS'), query: QuerySchema, data: WiresQueryResponseSchema, }), + z.object({ + type: z.literal('APPEND_RESULTS'), + data: WiresQueryResponseSchema, + }), z.object({ type: z.literal('FETCH_ERROR'), error: z.string() }), z.object({ type: z.literal('RETRY') }), z.object({ type: z.literal('SELECT_ITEM'), item: z.string().optional() }), @@ -100,6 +112,7 @@ export type SearchContextShape = { handleNextItem: () => void; handlePreviousItem: () => void; toggleAutoUpdate: () => void; + loadMoreResults: () => void; }; export const SearchContext: Context = createContext(null); @@ -167,9 +180,29 @@ export function SearchContextProvider({ children }: PropsWithChildren) { }); } - if (state.status === 'success' || state.status === 'offline') { + if (state.status === 'loading-more') { + fetchResults( + currentConfig.query, + undefined, + Math.min(...state.queryData.results.map((wire) => wire.id)).toString(), + ) + .then((data) => { + dispatch({ type: 'APPEND_RESULTS', data }); + }) + .catch((error) => { + const errorMessage = + error instanceof Error ? error.message : 'unknown error'; + dispatch({ type: 'FETCH_ERROR', error: errorMessage }); + }); + } + + if ( + state.status === 'success' || + state.status === 'offline' || + state.status === 'loading-more' + ) { pollingInterval = setInterval(() => { - if (state.autoUpdate) { + if (state.autoUpdate && state.status !== 'loading-more') { fetchResults( currentConfig.query, Math.max( @@ -273,6 +306,10 @@ export function SearchContextProvider({ children }: PropsWithChildren) { dispatch({ type: 'TOGGLE_AUTO_UPDATE' }); }; + const loadMoreResults = () => { + dispatch({ type: 'LOAD_MORE_RESULTS' }); + }; + return ( {children} diff --git a/newswires/client/src/context/SearchReducer.test.ts b/newswires/client/src/context/SearchReducer.test.ts index 37285280..c6011c85 100644 --- a/newswires/client/src/context/SearchReducer.test.ts +++ b/newswires/client/src/context/SearchReducer.test.ts @@ -15,6 +15,12 @@ describe('SearchReducer', () => { queryData: { results: [sampleWireData] }, }; + const loadingMoreState: State = { + ...initialState, + status: 'loading-more', + queryData: { results: [sampleWireData] }, + }; + const offlineState: State = { status: 'offline', queryData: { results: [sampleWireData] }, @@ -98,6 +104,31 @@ describe('SearchReducer', () => { }); }); + it(`should handle APPEND_RESULTS action in loading-more state`, () => { + const state: State = { + ...loadingMoreState, + queryData: { results: [{ ...sampleWireData, id: 2 }] }, + }; + + const action: Action = { + type: 'APPEND_RESULTS', + data: { results: [{ ...sampleWireData, id: 1 }] }, + }; + + const newState = SearchReducer(state, action); + + expect(newState.status).toBe('success'); + expect(newState.queryData?.results).toHaveLength(2); + expect(newState.queryData?.results).toContainEqual({ + ...sampleWireData, + id: 1, + }); + expect(newState.queryData?.results).toContainEqual({ + ...sampleWireData, + id: 2, + }); + }); + [ { state: initialState, expectedStatus: 'error' }, { state: successState, expectedStatus: 'offline' }, @@ -136,4 +167,16 @@ describe('SearchReducer', () => { expect(newState.status).toBe('loading'); }); }); + + [successState, offlineState].forEach((state) => { + it(`should handle LOAD_MORE_RESULTS action in ${state.status} state`, () => { + const action: Action = { + type: 'LOAD_MORE_RESULTS', + }; + + const newState = SearchReducer(state, action); + + expect(newState.status).toBe('loading-more'); + }); + }); }); diff --git a/newswires/client/src/context/SearchReducer.ts b/newswires/client/src/context/SearchReducer.ts index 5e5e6ad4..d9cb7803 100644 --- a/newswires/client/src/context/SearchReducer.ts +++ b/newswires/client/src/context/SearchReducer.ts @@ -29,6 +29,20 @@ function mergeQueryData( } } +function appendQueryData( + existing: WiresQueryResponse | undefined, + newData: WiresQueryResponse, +): WiresQueryResponse { + if (existing) { + return { + ...newData, + results: [...existing.results, ...newData.results], + }; + } else { + return newData; + } +} + function getUpdatedHistory( previousHistory: SearchHistory, newQuery: Query, @@ -85,6 +99,17 @@ export const SearchReducer = (state: State, action: Action): State => { default: return state; } + case 'APPEND_RESULTS': + switch (state.status) { + case 'loading-more': + return { + ...state, + status: 'success', + queryData: appendQueryData(state.queryData, action.data), + }; + default: + return state; + } case 'FETCH_ERROR': switch (state.status) { case 'loading': @@ -117,6 +142,18 @@ export const SearchReducer = (state: State, action: Action): State => { ...state, status: 'loading', }; + case 'LOAD_MORE_RESULTS': + switch (state.status) { + case 'success': + case 'offline': + return { + ...state, + status: 'loading-more', + }; + default: + return state; + } + default: return state; } diff --git a/newswires/client/src/context/fetchResults.ts b/newswires/client/src/context/fetchResults.ts index e25ee8d7..a00c80f4 100644 --- a/newswires/client/src/context/fetchResults.ts +++ b/newswires/client/src/context/fetchResults.ts @@ -6,11 +6,9 @@ import { paramsToQuerystring } from '../urlState.ts'; export const fetchResults = async ( query: Query, sinceId: string | undefined = undefined, + beforeId: string | undefined = undefined, ): Promise => { - const queryToSerialise = sinceId - ? { ...query, sinceId: sinceId.toString() } - : query; - const queryString = paramsToQuerystring(queryToSerialise); + const queryString = paramsToQuerystring(query, sinceId, beforeId); const response = await pandaFetch(`/api/search${queryString}`, { headers: { Accept: 'application/json', diff --git a/newswires/client/src/urlState.ts b/newswires/client/src/urlState.ts index 6d975027..79a30bf4 100644 --- a/newswires/client/src/urlState.ts +++ b/newswires/client/src/urlState.ts @@ -70,7 +70,8 @@ export const configToUrl = (config: Config): string => { export const paramsToQuerystring = ( config: Query, - sinceId: number | undefined = undefined, + sinceId: string | undefined = undefined, + beforeId: string | undefined = undefined, ): string => { const params = Object.entries(config).reduce>( (acc, [k, v]) => { @@ -89,7 +90,10 @@ export const paramsToQuerystring = ( [], ); if (sinceId !== undefined) { - params.push(['sinceId', sinceId.toString()]); + params.push(['sinceId', sinceId]); + } + if (beforeId !== undefined) { + params.push(['beforeId', beforeId]); } const querystring = new URLSearchParams(params).toString(); return querystring.length !== 0 ? `?${querystring}` : '';