diff --git a/newswires/client/src/WireItemTable.tsx b/newswires/client/src/WireItemTable.tsx index a56d1cc..536ab02 100644 --- a/newswires/client/src/WireItemTable.tsx +++ b/newswires/client/src/WireItemTable.tsx @@ -1,4 +1,5 @@ import { + EuiButton, EuiFlexGroup, euiScreenReaderOnly, EuiTable, @@ -13,6 +14,7 @@ import { useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; +import { useState } from 'react'; import sanitizeHtml from 'sanitize-html'; import { useSearch } from './context/SearchContext.tsx'; import { formatTimestamp } from './formatTimestamp'; @@ -31,38 +33,63 @@ const fadeOutBackground = css` `; export const WireItemTable = ({ wires }: { wires: WireData[] }) => { - const { config, handleSelectItem } = useSearch(); + const { config, handleSelectItem, loadMoreResults } = useSearch(); + + const [isLoadingMore, setIsLoadingMore] = useState(false); const selectedWireId = config.itemId; + const handleLoadMoreResults = () => { + if (wires.length > 0) { + setIsLoadingMore(true); + + const beforeId = Math.min(...wires.map((wire) => wire.id)).toString(); + + void loadMoreResults(beforeId).finally(() => { + setIsLoadingMore(false); + }); + } + }; + return ( - - + + + Headline + Version Created + + + {wires.map(({ id, supplier, content, isFromRefresh, highlight }) => ( + + ))} + + + - Headline - Version Created - - - {wires.map(({ id, supplier, content, isFromRefresh, highlight }) => ( - - ))} - - + {isLoadingMore ? 'Loading' : 'Load more'} + + ); }; @@ -101,6 +128,7 @@ const WireDataRow = ({ background-color: ${primaryBgColor}; border-left: 4px solid ${theme.euiTheme.colors.accent}; } + border-left: 4px solid ${selected ? theme.euiTheme.colors.primary : 'transparent'}; ${isFromRefresh ? fadeOutBackground : ''} diff --git a/newswires/client/src/context/SearchContext.tsx b/newswires/client/src/context/SearchContext.tsx index f6332a1..85a493e 100644 --- a/newswires/client/src/context/SearchContext.tsx +++ b/newswires/client/src/context/SearchContext.tsx @@ -77,6 +77,10 @@ const ActionSchema = z.discriminatedUnion('type', [ 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 +104,7 @@ export type SearchContextShape = { handleNextItem: () => void; handlePreviousItem: () => void; toggleAutoUpdate: () => void; + loadMoreResults: (beforeId: string) => Promise; }; export const SearchContext: Context = createContext(null); @@ -176,7 +181,7 @@ export function SearchContextProvider({ children }: PropsWithChildren) { ...state.queryData.results.map((wire) => wire.id), ).toString() : undefined; - fetchResults(currentConfig.query, sinceId) + fetchResults(currentConfig.query, { sinceId }) .then((data) => { dispatch({ type: 'UPDATE_RESULTS', data }); }) @@ -274,6 +279,18 @@ export function SearchContextProvider({ children }: PropsWithChildren) { dispatch({ type: 'TOGGLE_AUTO_UPDATE' }); }; + const loadMoreResults = async (beforeId: string): Promise => { + return fetchResults(currentConfig.query, { beforeId }) + .then((data) => { + dispatch({ type: 'APPEND_RESULTS', data }); + }) + .catch((error) => { + const errorMessage = + error instanceof Error ? error.message : 'unknown error'; + dispatch({ type: 'FETCH_ERROR', error: errorMessage }); + }); + }; + return ( {children} diff --git a/newswires/client/src/context/SearchReducer.test.ts b/newswires/client/src/context/SearchReducer.test.ts index 3728528..0e9ccef 100644 --- a/newswires/client/src/context/SearchReducer.test.ts +++ b/newswires/client/src/context/SearchReducer.test.ts @@ -98,6 +98,31 @@ describe('SearchReducer', () => { }); }); + it(`should handle APPEND_RESULTS action in success state`, () => { + const state: State = { + ...successState, + 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' }, diff --git a/newswires/client/src/context/SearchReducer.ts b/newswires/client/src/context/SearchReducer.ts index 5e5e6ad..265cabb 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,12 @@ export const SearchReducer = (state: State, action: Action): State => { default: return state; } + case 'APPEND_RESULTS': + return { + ...state, + status: 'success', + queryData: appendQueryData(state.queryData, action.data), + }; case 'FETCH_ERROR': switch (state.status) { case 'loading': diff --git a/newswires/client/src/context/fetchResults.test.ts b/newswires/client/src/context/fetchResults.test.ts index ae735f4..288c022 100644 --- a/newswires/client/src/context/fetchResults.test.ts +++ b/newswires/client/src/context/fetchResults.test.ts @@ -24,7 +24,7 @@ describe('fetchResults', () => { const mockQuery = { q: 'value' }; await fetchResults(mockQuery); - expect(paramsToQuerystring).toHaveBeenCalledWith(mockQuery); + expect(paramsToQuerystring).toHaveBeenCalledWith(mockQuery, {}); expect(pandaFetch).toHaveBeenCalledWith('/api/search?queryString', { headers: { Accept: 'application/json' }, }); @@ -70,11 +70,25 @@ describe('fetchResults', () => { it('should append sinceId to the query if provided', async () => { const mockQuery = { q: 'value' }; - await fetchResults(mockQuery, '123'); + await fetchResults(mockQuery, { sinceId: '123' }); - expect(paramsToQuerystring).toHaveBeenCalledWith({ - ...mockQuery, - sinceId: '123', - }); + expect(paramsToQuerystring).toHaveBeenCalledWith( + { + ...mockQuery, + }, + { sinceId: '123' }, + ); + }); + + it('should append beforeId to the query if provided', async () => { + const mockQuery = { q: 'value' }; + await fetchResults(mockQuery, { beforeId: '123' }); + + expect(paramsToQuerystring).toHaveBeenCalledWith( + { + ...mockQuery, + }, + { beforeId: '123' }, + ); }); }); diff --git a/newswires/client/src/context/fetchResults.ts b/newswires/client/src/context/fetchResults.ts index e25ee8d..caa7665 100644 --- a/newswires/client/src/context/fetchResults.ts +++ b/newswires/client/src/context/fetchResults.ts @@ -5,12 +5,12 @@ import { paramsToQuerystring } from '../urlState.ts'; export const fetchResults = async ( query: Query, - sinceId: string | undefined = undefined, + additionalParams: { + sinceId?: string; + beforeId?: string; + } = {}, ): Promise => { - const queryToSerialise = sinceId - ? { ...query, sinceId: sinceId.toString() } - : query; - const queryString = paramsToQuerystring(queryToSerialise); + const queryString = paramsToQuerystring(query, additionalParams); 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 6d97502..b5863ec 100644 --- a/newswires/client/src/urlState.ts +++ b/newswires/client/src/urlState.ts @@ -70,7 +70,13 @@ export const configToUrl = (config: Config): string => { export const paramsToQuerystring = ( config: Query, - sinceId: number | undefined = undefined, + { + sinceId, + beforeId, + }: { + sinceId?: string; + beforeId?: string; + } = {}, ): string => { const params = Object.entries(config).reduce>( (acc, [k, v]) => { @@ -89,7 +95,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}` : '';