Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Newswires UI: add pagination #108

Merged
merged 2 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 53 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 All @@ -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';
Expand All @@ -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<boolean>(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(() => {
sb-dev marked this conversation as resolved.
Show resolved Hide resolved
setIsLoadingMore(false);
});
}
};

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={isLoadingMore}
css={css`
${euiScreenReaderOnly()}
margin-top: 12px;
`}
onClick={handleLoadMoreResults}
>
<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>
{isLoadingMore ? 'Loading' : 'Load more'}
</EuiButton>
</>
);
};

Expand Down Expand Up @@ -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 : ''}
Expand Down
20 changes: 19 additions & 1 deletion newswires/client/src/context/SearchContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() }),
Expand All @@ -100,6 +104,7 @@ export type SearchContextShape = {
handleNextItem: () => void;
handlePreviousItem: () => void;
toggleAutoUpdate: () => void;
loadMoreResults: (beforeId: string) => Promise<void>;
};
export const SearchContext: Context<SearchContextShape | null> =
createContext<SearchContextShape | null>(null);
Expand Down Expand Up @@ -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 });
})
Expand Down Expand Up @@ -274,6 +279,18 @@ export function SearchContextProvider({ children }: PropsWithChildren) {
dispatch({ type: 'TOGGLE_AUTO_UPDATE' });
};

const loadMoreResults = async (beforeId: string): Promise<void> => {
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 (
<SearchContext.Provider
value={{
Expand All @@ -286,6 +303,7 @@ export function SearchContextProvider({ children }: PropsWithChildren) {
handleNextItem,
handlePreviousItem,
toggleAutoUpdate,
loadMoreResults,
}}
>
{children}
Expand Down
25 changes: 25 additions & 0 deletions newswires/client/src/context/SearchReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
20 changes: 20 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,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':
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
13 changes: 11 additions & 2 deletions newswires/client/src/urlState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<[string, string]>>(
(acc, [k, v]) => {
Expand All @@ -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}` : '';
Expand Down
Loading