From 2aec9457355e4da5bf2b861dfd846ee2d57808b5 Mon Sep 17 00:00:00 2001 From: Samir Benzenine Date: Fri, 24 Jan 2025 14:27:35 +0000 Subject: [PATCH] Newswires UI: Update pagination logic --- newswires/client/src/Feed.tsx | 4 +- newswires/client/src/WireItemTable.tsx | 29 +- .../client/src/context/SearchContext.tsx | 560 +++++++++--------- .../client/src/context/SearchReducer.test.ts | 20 +- newswires/client/src/context/SearchReducer.ts | 27 +- 5 files changed, 297 insertions(+), 343 deletions(-) diff --git a/newswires/client/src/Feed.tsx b/newswires/client/src/Feed.tsx index 44bbe7c9..6a45fa81 100644 --- a/newswires/client/src/Feed.tsx +++ b/newswires/client/src/Feed.tsx @@ -25,9 +25,7 @@ export const Feed = () => { titleSize="s" /> )} - {(status == 'success' || - status == 'offline' || - status == 'loading-more') && + {(status == 'success' || status == 'offline') && queryData.results.length > 0 && ( )} diff --git a/newswires/client/src/WireItemTable.tsx b/newswires/client/src/WireItemTable.tsx index fefbf611..affdbf73 100644 --- a/newswires/client/src/WireItemTable.tsx +++ b/newswires/client/src/WireItemTable.tsx @@ -14,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'; @@ -32,15 +33,24 @@ const fadeOutBackground = css` `; export const WireItemTable = ({ wires }: { wires: WireData[] }) => { - const { - config, - handleSelectItem, - state: { status }, - loadMoreResults, - } = useSearch(); + const { config, handleSelectItem, loadMoreResults } = useSearch(); + + const [isLoading, setIsLoading] = useState(false); const selectedWireId = config.itemId; + const handleLoadMoreResults = () => { + if (wires.length > 0) { + setIsLoading(true); + + const beforeId = Math.min(...wires.map((wire) => wire.id)).toString(); + + void loadMoreResults(beforeId).finally(() => { + setIsLoading(false); + }); + } + }; + return ( <> { - {status === 'loading-more' ? 'Loading' : 'Load more'} + {isLoading ? 'Loading' : 'Load more'} ); @@ -118,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 b7d25840..72fd78e7 100644 --- a/newswires/client/src/context/SearchContext.tsx +++ b/newswires/client/src/context/SearchContext.tsx @@ -1,76 +1,69 @@ -import type { Context, PropsWithChildren } from 'react'; +import type {Context, PropsWithChildren} from 'react'; import { - createContext, - useCallback, - useContext, - useEffect, - useReducer, - useState, + createContext, + useCallback, + useContext, + useEffect, + useReducer, + useState, } from 'react'; -import { z } from 'zod'; -import type { Config, Query } from '../sharedTypes.ts'; +import {z} from 'zod'; +import type {Config, Query} from '../sharedTypes.ts'; import { - ConfigSchema, - QuerySchema, - WiresQueryResponseSchema, + ConfigSchema, + QuerySchema, + WiresQueryResponseSchema, } from '../sharedTypes.ts'; -import { configToUrl, defaultConfig, urlToConfig } from '../urlState.ts'; -import { fetchResults } from './fetchResults.ts'; -import { SearchReducer } from './SearchReducer.ts'; +import {configToUrl, defaultConfig, urlToConfig} from '../urlState.ts'; +import {fetchResults} from './fetchResults.ts'; +import {SearchReducer} from './SearchReducer.ts'; const SearchHistorySchema = z.array( - z.object({ - query: QuerySchema, - resultsCount: z.number(), - }), + z.object({ + query: QuerySchema, + resultsCount: z.number(), + }), ); export type SearchHistory = z.infer; // State Schema const StateSchema = z.discriminatedUnion('status', [ - z.object({ - status: z.literal('initialised'), - error: z.string().optional(), - queryData: WiresQueryResponseSchema.optional(), - successfulQueryHistory: SearchHistorySchema, - autoUpdate: z.boolean().default(true), - }), - z.object({ - status: z.literal('loading'), - error: z.string().optional(), - queryData: WiresQueryResponseSchema.optional(), - 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(), - queryData: WiresQueryResponseSchema, - successfulQueryHistory: SearchHistorySchema, - autoUpdate: z.boolean().default(true), - }), - z.object({ - status: z.literal('error'), - error: z.string(), - queryData: WiresQueryResponseSchema.optional(), - successfulQueryHistory: SearchHistorySchema, - autoUpdate: z.boolean().default(true), - }), - z.object({ - status: z.literal('offline'), - error: z.string(), - queryData: WiresQueryResponseSchema, - successfulQueryHistory: SearchHistorySchema, - autoUpdate: z.boolean().default(true), - }), + z.object({ + status: z.literal('initialised'), + error: z.string().optional(), + queryData: WiresQueryResponseSchema.optional(), + successfulQueryHistory: SearchHistorySchema, + autoUpdate: z.boolean().default(true), + }), + z.object({ + status: z.literal('loading'), + error: z.string().optional(), + queryData: WiresQueryResponseSchema.optional(), + successfulQueryHistory: SearchHistorySchema, + autoUpdate: z.boolean().default(true), + }), + z.object({ + status: z.literal('success'), + error: z.string().optional(), + queryData: WiresQueryResponseSchema, + successfulQueryHistory: SearchHistorySchema, + autoUpdate: z.boolean().default(true), + }), + z.object({ + status: z.literal('error'), + error: z.string(), + queryData: WiresQueryResponseSchema.optional(), + successfulQueryHistory: SearchHistorySchema, + autoUpdate: z.boolean().default(true), + }), + z.object({ + status: z.literal('offline'), + error: z.string(), + queryData: WiresQueryResponseSchema, + successfulQueryHistory: SearchHistorySchema, + autoUpdate: z.boolean().default(true), + }), ]); // Infer State Type @@ -78,263 +71,250 @@ 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() }), - z.object({ - type: z.literal('UPDATE_RESULTS'), - data: WiresQueryResponseSchema, - }), - z.object({ type: z.literal('TOGGLE_AUTO_UPDATE') }), + z.object({type: z.literal('ENTER_QUERY')}), + 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()}), + z.object({ + type: z.literal('UPDATE_RESULTS'), + data: WiresQueryResponseSchema, + }), + z.object({type: z.literal('TOGGLE_AUTO_UPDATE')}), ]); // Infer Action Type export type Action = z.infer; export type SearchContextShape = { - config: Config; - state: State; - handleEnterQuery: (query: Query) => void; - handleRetry: () => void; - handleSelectItem: (item: string) => void; - handleDeselectItem: () => void; - handleNextItem: () => void; - handlePreviousItem: () => void; - toggleAutoUpdate: () => void; - loadMoreResults: () => void; + config: Config; + state: State; + handleEnterQuery: (query: Query) => void; + handleRetry: () => void; + handleSelectItem: (item: string) => void; + handleDeselectItem: () => void; + handleNextItem: () => void; + handlePreviousItem: () => void; + toggleAutoUpdate: () => void; + loadMoreResults: (beforeId: string) => Promise; }; export const SearchContext: Context = - createContext(null); + createContext(null); -export function SearchContextProvider({ children }: PropsWithChildren) { - const [currentConfig, setConfig] = useState( - urlToConfig(window.location), - ); +export function SearchContextProvider({children}: PropsWithChildren) { + const [currentConfig, setConfig] = useState( + urlToConfig(window.location), + ); - const [state, dispatch] = useReducer(SearchReducer, { - error: undefined, - queryData: undefined, - successfulQueryHistory: [], - status: 'loading', - autoUpdate: true, - }); + const [state, dispatch] = useReducer(SearchReducer, { + error: undefined, + queryData: undefined, + successfulQueryHistory: [], + status: 'loading', + autoUpdate: true, + }); - const pushConfigState = useCallback( - (config: Config) => { - history.pushState(config, '', configToUrl(config)); - setConfig(config); - }, - [setConfig], - ); + const pushConfigState = useCallback( + (config: Config) => { + history.pushState(config, '', configToUrl(config)); + setConfig(config); + }, + [setConfig], + ); - const popConfigStateCallback = useCallback( - (e: PopStateEvent) => { - const configParseResult = ConfigSchema.safeParse(e.state); - if (configParseResult.success) { - setConfig(configParseResult.data); - } else { - setConfig(defaultConfig); - } - }, - [setConfig], - ); + const popConfigStateCallback = useCallback( + (e: PopStateEvent) => { + const configParseResult = ConfigSchema.safeParse(e.state); + if (configParseResult.success) { + setConfig(configParseResult.data); + } else { + setConfig(defaultConfig); + } + }, + [setConfig], + ); - useEffect(() => { - if (window.history.state === null) { - window.history.replaceState( - currentConfig, - '', - configToUrl(currentConfig), - ); - } - }, [currentConfig]); + useEffect(() => { + if (window.history.state === null) { + window.history.replaceState( + currentConfig, + '', + configToUrl(currentConfig), + ); + } + }, [currentConfig]); - useEffect(() => { - window.addEventListener('popstate', popConfigStateCallback); - return () => window.removeEventListener('popstate', popConfigStateCallback); - }, [popConfigStateCallback]); + useEffect(() => { + window.addEventListener('popstate', popConfigStateCallback); + return () => window.removeEventListener('popstate', popConfigStateCallback); + }, [popConfigStateCallback]); - useEffect(() => { - let pollingInterval: NodeJS.Timeout | undefined; + useEffect(() => { + let pollingInterval: NodeJS.Timeout | undefined; - if (state.status === 'loading') { - fetchResults(currentConfig.query) - .then((data) => { - dispatch({ type: 'FETCH_SUCCESS', data, query: currentConfig.query }); - }) - .catch((error) => { - const errorMessage = - error instanceof Error ? error.message : 'unknown error'; - dispatch({ type: 'FETCH_ERROR', error: errorMessage }); - }); - } + if (state.status === 'loading') { + fetchResults(currentConfig.query) + .then((data) => { + dispatch({type: 'FETCH_SUCCESS', data, query: currentConfig.query}); + }) + .catch((error) => { + const errorMessage = + error instanceof Error ? error.message : 'unknown error'; + dispatch({type: 'FETCH_ERROR', error: errorMessage}); + }); + } - if (state.status === 'loading-more') { - fetchResults(currentConfig.query, { - beforeId: 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') { + pollingInterval = setInterval(() => { + if (state.autoUpdate) { + const sinceId = + state.queryData.results.length > 0 + ? Math.max( + ...state.queryData.results.map((wire) => wire.id), + ).toString() + : undefined; + fetchResults(currentConfig.query, {sinceId}) + .then((data) => { + dispatch({type: 'UPDATE_RESULTS', data}); + }) + .catch((error) => { + const errorMessage = + error instanceof Error ? error.message : 'unknown error'; + dispatch({type: 'FETCH_ERROR', error: errorMessage}); + }); + } + }, 6000); + } - if ( - state.status === 'success' || - state.status === 'offline' || - state.status === 'loading-more' - ) { - pollingInterval = setInterval(() => { - if (state.autoUpdate) { - const sinceId = - state.queryData.results.length > 0 - ? Math.max( - ...state.queryData.results.map((wire) => wire.id), - ).toString() - : undefined; - fetchResults(currentConfig.query, sinceId) - .then((data) => { - dispatch({ type: 'UPDATE_RESULTS', data }); - }) - .catch((error) => { - const errorMessage = - error instanceof Error ? error.message : 'unknown error'; - dispatch({ type: 'FETCH_ERROR', error: errorMessage }); - }); - } - }, 6000); - } + return () => { + if (pollingInterval) { + clearInterval(pollingInterval); + } + }; + }, [ + state.status, + state.autoUpdate, + currentConfig.query, + state.queryData?.results, + ]); - return () => { - if (pollingInterval) { - clearInterval(pollingInterval); - } - }; - }, [ - state.status, - state.autoUpdate, - currentConfig.query, - state.queryData?.results, - ]); + const handleEnterQuery = (query: Query) => { + dispatch({type: 'ENTER_QUERY'}); + if (currentConfig.view === 'item') { + pushConfigState({ + ...currentConfig, + query, + }); + return; + } + pushConfigState({ + ...currentConfig, + view: 'feed', + query, + }); + }; - const handleEnterQuery = (query: Query) => { - dispatch({ type: 'ENTER_QUERY' }); - if (currentConfig.view === 'item') { - pushConfigState({ - ...currentConfig, - query, - }); - return; - } - pushConfigState({ - ...currentConfig, - view: 'feed', - query, - }); - }; + const handleRetry = () => { + dispatch({type: 'RETRY'}); + }; - const handleRetry = () => { - dispatch({ type: 'RETRY' }); - }; + const handleSelectItem = (item: string) => + pushConfigState({ + view: 'item', + itemId: item, + query: currentConfig.query, + }); - const handleSelectItem = (item: string) => - pushConfigState({ - view: 'item', - itemId: item, - query: currentConfig.query, - }); + const handleDeselectItem = () => { + pushConfigState({view: 'feed', query: currentConfig.query}); + }; - const handleDeselectItem = () => { - pushConfigState({ view: 'feed', query: currentConfig.query }); - }; + const handleNextItem = () => { + const results = state.queryData?.results; + const currentItemId = currentConfig.itemId; + if (!results || !currentItemId) { + return; + } + const currentIndex = results.findIndex( + (wire) => wire.id.toString() === currentItemId, + ); + if (currentIndex === -1) { + return undefined; + } + const nextIndex = currentIndex + 1; + if (nextIndex >= results.length) { + return undefined; + } + handleSelectItem(results[nextIndex].id.toString()); + }; - const handleNextItem = () => { - const results = state.queryData?.results; - const currentItemId = currentConfig.itemId; - if (!results || !currentItemId) { - return; - } - const currentIndex = results.findIndex( - (wire) => wire.id.toString() === currentItemId, - ); - if (currentIndex === -1) { - return undefined; - } - const nextIndex = currentIndex + 1; - if (nextIndex >= results.length) { - return undefined; - } - handleSelectItem(results[nextIndex].id.toString()); - }; + const handlePreviousItem = () => { + const results = state.queryData?.results; + const currentItemId = currentConfig.itemId; + if (!results || !currentItemId) { + return; + } + const currentIndex = results.findIndex( + (wire) => wire.id.toString() === currentItemId, + ); + if (currentIndex === -1) { + return undefined; + } + const previousIndex = currentIndex - 1; + if (previousIndex < 0) { + return undefined; + } + handleSelectItem(results[previousIndex].id.toString()); + }; - const handlePreviousItem = () => { - const results = state.queryData?.results; - const currentItemId = currentConfig.itemId; - if (!results || !currentItemId) { - return; - } - const currentIndex = results.findIndex( - (wire) => wire.id.toString() === currentItemId, - ); - if (currentIndex === -1) { - return undefined; - } - const previousIndex = currentIndex - 1; - if (previousIndex < 0) { - return undefined; - } - handleSelectItem(results[previousIndex].id.toString()); - }; + const toggleAutoUpdate = () => { + dispatch({type: 'TOGGLE_AUTO_UPDATE'}); + }; - const toggleAutoUpdate = () => { - 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}); + }); + }; - const loadMoreResults = () => { - dispatch({ type: 'LOAD_MORE_RESULTS' }); - }; - - return ( - - {children} - - ); + return ( + + {children} + + ); } export const useSearch = () => { - const searchContext = useContext(SearchContext); - if (searchContext === null) { - throw new Error('useSearch must be used within a SearchContextProvider'); - } - return searchContext; + const searchContext = useContext(SearchContext); + if (searchContext === null) { + throw new Error('useSearch must be used within a SearchContextProvider'); + } + return searchContext; }; diff --git a/newswires/client/src/context/SearchReducer.test.ts b/newswires/client/src/context/SearchReducer.test.ts index c6011c85..f62b0810 100644 --- a/newswires/client/src/context/SearchReducer.test.ts +++ b/newswires/client/src/context/SearchReducer.test.ts @@ -15,12 +15,6 @@ describe('SearchReducer', () => { queryData: { results: [sampleWireData] }, }; - const loadingMoreState: State = { - ...initialState, - status: 'loading-more', - queryData: { results: [sampleWireData] }, - }; - const offlineState: State = { status: 'offline', queryData: { results: [sampleWireData] }, @@ -106,7 +100,7 @@ describe('SearchReducer', () => { it(`should handle APPEND_RESULTS action in loading-more state`, () => { const state: State = { - ...loadingMoreState, + ...successState, queryData: { results: [{ ...sampleWireData, id: 2 }] }, }; @@ -167,16 +161,4 @@ 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 d9cb7803..265cabb1 100644 --- a/newswires/client/src/context/SearchReducer.ts +++ b/newswires/client/src/context/SearchReducer.ts @@ -100,16 +100,11 @@ export const SearchReducer = (state: State, action: Action): State => { return state; } case 'APPEND_RESULTS': - switch (state.status) { - case 'loading-more': - return { - ...state, - status: 'success', - queryData: appendQueryData(state.queryData, action.data), - }; - default: - return state; - } + return { + ...state, + status: 'success', + queryData: appendQueryData(state.queryData, action.data), + }; case 'FETCH_ERROR': switch (state.status) { case 'loading': @@ -142,18 +137,6 @@ 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; }