Skip to content

Commit

Permalink
Newswires UI: add pagination
Browse files Browse the repository at this point in the history
  • Loading branch information
sb-dev committed Jan 23, 2025
1 parent 4cb3429 commit 6b19f70
Show file tree
Hide file tree
Showing 8 changed files with 204 additions and 45 deletions.
4 changes: 3 additions & 1 deletion newswires/client/src/Feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ export const Feed = () => {
titleSize="s"
/>
)}
{(status == 'success' || status == 'offline') &&
{(status == 'success' ||
status == 'offline' ||
status == 'loading-more') &&
queryData.results.length > 0 && (
<WireItemTable wires={queryData.results} />
)}
Expand Down
67 changes: 42 additions & 25 deletions newswires/client/src/WireItemTable.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
EuiButton,
EuiFlexGroup,
euiScreenReaderOnly,
EuiTable,
Expand Down Expand Up @@ -31,38 +32,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 (
<EuiTable
tableLayout="auto"
responsiveBreakpoint={config.view === 'item' ? true : 'm'}
>
<EuiTableHeader
<>
<EuiTable
tableLayout="auto"
responsiveBreakpoint={config.view === 'item' ? true : 'm'}
>
<EuiTableHeader
css={css`
${euiScreenReaderOnly()}
`}
>
<EuiTableHeaderCell>Headline</EuiTableHeaderCell>
<EuiTableHeaderCell>Version Created</EuiTableHeaderCell>
</EuiTableHeader>
<EuiTableBody>
{wires.map(({ id, supplier, content, isFromRefresh, highlight }) => (
<WireDataRow
key={id}
id={id}
supplier={supplier}
content={content}
isFromRefresh={isFromRefresh}
highlight={highlight}
selected={selectedWireId == id.toString()}
handleSelect={handleSelectItem}
/>
))}
</EuiTableBody>
</EuiTable>
<EuiButton
isLoading={status === 'loading-more'}
css={css`
${euiScreenReaderOnly()}
margin-top: 12px;
`}
onClick={loadMoreResults}
>
<EuiTableHeaderCell>Headline</EuiTableHeaderCell>
<EuiTableHeaderCell>Version Created</EuiTableHeaderCell>
</EuiTableHeader>
<EuiTableBody>
{wires.map(({ id, supplier, content, isFromRefresh, highlight }) => (
<WireDataRow
key={id}
id={id}
supplier={supplier}
content={content}
isFromRefresh={isFromRefresh}
highlight={highlight}
selected={selectedWireId == id.toString()}
handleSelect={handleSelectItem}
/>
))}
</EuiTableBody>
</EuiTable>
{status === 'loading-more' ? 'Loading' : 'Load more'}
</EuiButton>
</>
);
};

Expand Down
49 changes: 43 additions & 6 deletions newswires/client/src/context/SearchContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -72,11 +79,16 @@ export type State = z.infer<typeof StateSchema>;
// 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() }),
Expand All @@ -100,6 +112,7 @@ export type SearchContextShape = {
handleNextItem: () => void;
handlePreviousItem: () => void;
toggleAutoUpdate: () => void;
loadMoreResults: () => void;
};
export const SearchContext: Context<SearchContextShape | null> =
createContext<SearchContextShape | null>(null);
Expand Down Expand Up @@ -167,15 +180,34 @@ export function SearchContextProvider({ children }: PropsWithChildren) {
});
}

if (state.status === 'success' || state.status === 'offline') {
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' ||
state.status === 'loading-more'
) {
pollingInterval = setInterval(() => {
if (state.autoUpdate) {
fetchResults(
currentConfig.query,
Math.max(
if (state.autoUpdate && state.status !== 'loading-more') {
fetchResults(currentConfig.query, {
sinceId: Math.max(
...state.queryData.results.map((wire) => wire.id),
).toString(),
)
})
.then((data) => {
dispatch({ type: 'UPDATE_RESULTS', data });
})
Expand Down Expand Up @@ -273,6 +305,10 @@ export function SearchContextProvider({ children }: PropsWithChildren) {
dispatch({ type: 'TOGGLE_AUTO_UPDATE' });
};

const loadMoreResults = () => {
dispatch({ type: 'LOAD_MORE_RESULTS' });
};

return (
<SearchContext.Provider
value={{
Expand All @@ -285,6 +321,7 @@ export function SearchContextProvider({ children }: PropsWithChildren) {
handleNextItem,
handlePreviousItem,
toggleAutoUpdate,
loadMoreResults,
}}
>
{children}
Expand Down
43 changes: 43 additions & 0 deletions newswires/client/src/context/SearchReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] },
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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');
});
});
});
37 changes: 37 additions & 0 deletions newswires/client/src/context/SearchReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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;
}
Expand Down
26 changes: 20 additions & 6 deletions newswires/client/src/context/fetchResults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
});
Expand Down Expand Up @@ -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' },
);
});
});
10 changes: 5 additions & 5 deletions newswires/client/src/context/fetchResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WiresQueryResponse> => {
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',
Expand Down
Loading

0 comments on commit 6b19f70

Please sign in to comment.