pl-fe: migrate status quotes list to tanstack query

Signed-off-by: mkljczk <git@mkljczk.pl>
This commit is contained in:
mkljczk 2024-11-30 20:47:03 +01:00
parent d95a78085e
commit a99c61d454
7 changed files with 43 additions and 322 deletions

View file

@ -1,157 +0,0 @@
import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'pl-fe/api';
import { mockStore, rootState } from 'pl-fe/jest/test-helpers';
import { StatusListRecord } from 'pl-fe/reducers/status-lists';
import { fetchStatusQuotes, expandStatusQuotes } from './status-quotes';
const status = {
account: {
id: 'ABDSjI3Q0R8aDaz1U0',
},
content: 'quoast',
id: 'AJsajx9hY4Q7IKQXEe',
pleroma: {
quote: {
content: '<p>10</p>',
id: 'AJmoVikzI3SkyITyim',
},
},
};
const statusId = 'AJmoVikzI3SkyITyim';
describe('fetchStatusQuotes()', () => {
let store: ReturnType<typeof mockStore>;
beforeEach(() => {
const state = { ...rootState, me: '1234' };
store = mockStore(state);
});
describe('with a successful API request', () => {
beforeEach(async () => {
const quotes = await import('pl-fe/__fixtures__/status-quotes.json');
__stub((mock) => {
mock.onGet(`/api/v1/pleroma/statuses/${statusId}/quotes`).reply(200, quotes, {
link: `<https://example.com/api/v1/pleroma/statuses/${statusId}/quotes?since_id=1>; rel='prev'`,
});
});
});
it('should fetch quotes from the API', async() => {
const expectedActions = [
{ type: 'STATUS_QUOTES_FETCH_REQUEST', statusId },
{ type: 'POLLS_IMPORT', polls: [] },
{ type: 'ACCOUNTS_IMPORT', accounts: [status.account] },
{ type: 'STATUSES_IMPORT', statuses: [status] },
{ type: 'STATUS_QUOTES_FETCH_SUCCESS', statusId, statuses: [status], next: null },
];
await store.dispatch(fetchStatusQuotes(statusId));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/pleroma/statuses/${statusId}/quotes`).networkError();
});
});
it('should dispatch failed action', async() => {
const expectedActions = [
{ type: 'STATUS_QUOTES_FETCH_REQUEST', statusId },
{ type: 'STATUS_QUOTES_FETCH_FAIL', statusId, error: new Error('Network Error') },
];
await store.dispatch(fetchStatusQuotes(statusId));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
describe('expandStatusQuotes()', () => {
let store: ReturnType<typeof mockStore>;
describe('without a url', () => {
beforeEach(() => {
const state = {
...rootState,
me: '1234',
status_lists: ImmutableMap({ [`quotes:${statusId}`]: StatusListRecord({ next: null }) }),
};
store = mockStore(state);
});
it('should do nothing', async() => {
await store.dispatch(expandStatusQuotes(statusId));
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('with a url', () => {
beforeEach(() => {
const state = {
...rootState,
status_lists: ImmutableMap({ [`quotes:${statusId}`]: StatusListRecord({ next: 'example' }) }),
me: '1234',
};
store = mockStore(state);
});
describe('with a successful API request', () => {
beforeEach(async () => {
const quotes = await import('pl-fe/__fixtures__/status-quotes.json');
__stub((mock) => {
mock.onGet('example').reply(200, quotes, {
link: `<https://example.com/api/v1/pleroma/statuses/${statusId}/quotes?since_id=1>; rel='prev'`,
});
});
});
it('should fetch quotes from the API', async() => {
const expectedActions = [
{ type: 'STATUS_QUOTES_EXPAND_REQUEST', statusId },
{ type: 'POLLS_IMPORT', polls: [] },
{ type: 'ACCOUNTS_IMPORT', accounts: [status.account] },
{ type: 'STATUSES_IMPORT', statuses: [status] },
{ type: 'STATUS_QUOTES_EXPAND_SUCCESS', statusId, statuses: [status], next: null },
];
await store.dispatch(expandStatusQuotes(statusId));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('example').networkError();
});
});
it('should dispatch failed action', async() => {
const expectedActions = [
{ type: 'STATUS_QUOTES_EXPAND_REQUEST', statusId },
{ type: 'STATUS_QUOTES_EXPAND_FAIL', statusId, error: new Error('Network Error') },
];
await store.dispatch(expandStatusQuotes(statusId));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
});

View file

@ -1,127 +0,0 @@
import { getClient } from '../api';
import { importEntities } from './importer';
import type { Status as BaseStatus, PaginatedResponse } from 'pl-api';
import type { AppDispatch, RootState } from 'pl-fe/store';
const STATUS_QUOTES_FETCH_REQUEST = 'STATUS_QUOTES_FETCH_REQUEST' as const;
const STATUS_QUOTES_FETCH_SUCCESS = 'STATUS_QUOTES_FETCH_SUCCESS' as const;
const STATUS_QUOTES_FETCH_FAIL = 'STATUS_QUOTES_FETCH_FAIL' as const;
const STATUS_QUOTES_EXPAND_REQUEST = 'STATUS_QUOTES_EXPAND_REQUEST' as const;
const STATUS_QUOTES_EXPAND_SUCCESS = 'STATUS_QUOTES_EXPAND_SUCCESS' as const;
const STATUS_QUOTES_EXPAND_FAIL = 'STATUS_QUOTES_EXPAND_FAIL' as const;
const noOp = () => new Promise(f => f(null));
interface FetchStatusQuotesRequestAction {
type: typeof STATUS_QUOTES_FETCH_REQUEST;
statusId: string;
}
interface FetchStatusQuotesSuccessAction {
type: typeof STATUS_QUOTES_FETCH_SUCCESS;
statusId: string;
statuses: Array<BaseStatus>;
next: (() => Promise<PaginatedResponse<BaseStatus>>) | null;
}
interface FetchStatusQuotesFailAction {
type: typeof STATUS_QUOTES_FETCH_FAIL;
statusId: string;
error: unknown;
}
const fetchStatusQuotes = (statusId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (getState().status_lists[`quotes:${statusId}`]?.isLoading) {
return dispatch(noOp);
}
dispatch<FetchStatusQuotesRequestAction>({ type: STATUS_QUOTES_FETCH_REQUEST, statusId });
return getClient(getState).statuses.getStatusQuotes(statusId).then(response => {
dispatch(importEntities({ statuses: response.items }));
return dispatch<FetchStatusQuotesSuccessAction>({
type: STATUS_QUOTES_FETCH_SUCCESS,
statusId,
statuses: response.items,
next: response.next,
});
}).catch(error => {
dispatch<FetchStatusQuotesFailAction>({
type: STATUS_QUOTES_FETCH_FAIL,
statusId,
error,
});
});
};
interface ExpandStatusQuotesRequestAction {
type: typeof STATUS_QUOTES_EXPAND_REQUEST;
statusId: string;
}
interface ExpandStatusQuotesSuccessAction {
type: typeof STATUS_QUOTES_EXPAND_SUCCESS;
statusId: string;
statuses: Array<BaseStatus>;
next: (() => Promise<PaginatedResponse<BaseStatus>>) | null;
}
interface ExpandStatusQuotesFailAction {
type: typeof STATUS_QUOTES_EXPAND_FAIL;
statusId: string;
error: unknown;
}
const expandStatusQuotes = (statusId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const next = getState().status_lists[`quotes:${statusId}`]?.next || null;
if (next === null || getState().status_lists[`quotes:${statusId}`]?.isLoading) {
return dispatch(noOp);
}
dispatch<ExpandStatusQuotesRequestAction>({
type: STATUS_QUOTES_EXPAND_REQUEST,
statusId,
});
return next().then(response => {
dispatch(importEntities({ statuses: response.items }));
dispatch<ExpandStatusQuotesSuccessAction>({
type: STATUS_QUOTES_EXPAND_SUCCESS,
statusId,
statuses: response.items,
next: response.next,
});
}).catch(error => {
dispatch<ExpandStatusQuotesFailAction>({
type: STATUS_QUOTES_EXPAND_FAIL,
statusId,
error,
});
});
};
type StatusQuotesAction =
| FetchStatusQuotesRequestAction
| FetchStatusQuotesSuccessAction
| FetchStatusQuotesFailAction
| ExpandStatusQuotesRequestAction
| ExpandStatusQuotesSuccessAction
| ExpandStatusQuotesFailAction;
export {
STATUS_QUOTES_FETCH_REQUEST,
STATUS_QUOTES_FETCH_SUCCESS,
STATUS_QUOTES_FETCH_FAIL,
STATUS_QUOTES_EXPAND_REQUEST,
STATUS_QUOTES_EXPAND_SUCCESS,
STATUS_QUOTES_EXPAND_FAIL,
fetchStatusQuotes,
expandStatusQuotes,
type StatusQuotesAction,
};

View file

@ -0,0 +1,20 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { minifyStatusList } from 'pl-fe/api/normalizers/status-list';
import { useClient } from 'pl-fe/hooks/use-client';
import type { PaginatedResponse } from 'pl-api';
const useStatusQuotes = (statusId: string) => {
const client = useClient();
return useInfiniteQuery({
queryKey: ['statusLists', 'quotes', statusId],
queryFn: ({ pageParam }) => pageParam.next?.() || client.statuses.getStatusQuotes(statusId).then(minifyStatusList),
initialPageParam: { previous: null, next: null, items: [], partial: false } as PaginatedResponse<string>,
getNextPageParam: (page) => page.next ? page : undefined,
select: (data) => data.pages.map(page => page.items).flat(),
});
};
export { useStatusQuotes };

View file

@ -0,0 +1,17 @@
import { PaginatedResponse, Status } from 'pl-api';
import { importEntities } from 'pl-fe/actions/importer';
import { store } from 'pl-fe/store';
const minifyStatusList = ({ previous, next, items, ...response }: PaginatedResponse<Status>): PaginatedResponse<string> => {
store.dispatch(importEntities({ statuses: items }) as any);
return {
...response,
previous: previous ? () => previous().then(minifyStatusList) : null,
next: next ? () => next().then(minifyStatusList) : null,
items: items.map(status => status.id),
};
};
export { minifyStatusList };

View file

@ -1,13 +1,10 @@
import debounce from 'lodash/debounce';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useParams } from 'react-router-dom';
import { expandStatusQuotes, fetchStatusQuotes } from 'pl-fe/actions/status-quotes';
import { useStatusQuotes } from 'pl-fe/api/hooks/statuses/use-status-quotes';
import StatusList from 'pl-fe/components/status-list';
import Column from 'pl-fe/components/ui/column';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useIsMobile } from 'pl-fe/hooks/use-is-mobile';
import { useTheme } from 'pl-fe/hooks/use-theme';
@ -15,23 +12,13 @@ const messages = defineMessages({
heading: { id: 'column.quotes', defaultMessage: 'Post quotes' },
});
const handleLoadMore = debounce((statusId: string, dispatch: React.Dispatch<any>) =>
dispatch(expandStatusQuotes(statusId)), 300, { leading: true });
const Quotes: React.FC = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { statusId } = useParams<{ statusId: string }>();
const theme = useTheme();
const isMobile = useIsMobile();
const statusIds = useAppSelector((state) => state.status_lists[`quotes:${statusId}`]?.items || []);
const isLoading = useAppSelector((state) => state.status_lists[`quotes:${statusId}`]?.isLoading !== false);
const hasMore = useAppSelector((state) => !!state.status_lists[`quotes:${statusId}`]?.next);
React.useEffect(() => {
dispatch(fetchStatusQuotes(statusId));
}, [statusId]);
const { data: statusIds = [], isLoading, hasNextPage, fetchNextPage } = useStatusQuotes(statusId);
const emptyMessage = <FormattedMessage id='empty_column.quotes' defaultMessage='This post has not been quoted yet.' />;
@ -42,9 +29,9 @@ const Quotes: React.FC = () => {
loadMoreClassName='black:sm:mx-4'
statusIds={statusIds}
scrollKey={`quotes:${statusId}`}
hasMore={hasMore}
hasMore={hasNextPage}
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
onLoadMore={() => handleLoadMore(statusId, dispatch)}
onLoadMore={() => fetchNextPage({ cancelRefetch: false })}
emptyMessage={emptyMessage}
divideType={(theme === 'black' || isMobile) ? 'border' : 'space'}
/>

View file

@ -54,15 +54,6 @@ import {
SCHEDULED_STATUS_CANCEL_SUCCESS,
type ScheduledStatusesAction,
} from 'pl-fe/actions/scheduled-statuses';
import {
STATUS_QUOTES_EXPAND_FAIL,
STATUS_QUOTES_EXPAND_REQUEST,
STATUS_QUOTES_EXPAND_SUCCESS,
STATUS_QUOTES_FETCH_FAIL,
STATUS_QUOTES_FETCH_REQUEST,
STATUS_QUOTES_FETCH_SUCCESS,
type StatusQuotesAction,
} from 'pl-fe/actions/status-quotes';
import { STATUS_CREATE_SUCCESS, type StatusesAction } from 'pl-fe/actions/statuses';
import type { PaginatedResponse, ScheduledStatus, Status } from 'pl-api';
@ -155,7 +146,7 @@ const removeBookmarkFromLists = (state: State, status: Pick<Status, 'id' | 'book
}
};
const statusLists = (state = initialState, action: BookmarksAction | EventsAction | FavouritesAction | InteractionsAction | PinStatusesAction | ScheduledStatusesAction | StatusesAction | StatusQuotesAction): State => {
const statusLists = (state = initialState, action: BookmarksAction | EventsAction | FavouritesAction | InteractionsAction | PinStatusesAction | ScheduledStatusesAction | StatusesAction): State => {
switch (action.type) {
case FAVOURITED_STATUSES_FETCH_REQUEST:
case FAVOURITED_STATUSES_EXPAND_REQUEST:
@ -214,16 +205,6 @@ const statusLists = (state = initialState, action: BookmarksAction | EventsActio
case SCHEDULED_STATUS_CANCEL_REQUEST:
case SCHEDULED_STATUS_CANCEL_SUCCESS:
return create(state, draft => removeOneFromList(draft, 'scheduled_statuses', action.statusId));
case STATUS_QUOTES_FETCH_REQUEST:
case STATUS_QUOTES_EXPAND_REQUEST:
return create(state, draft => setLoading(draft, `quotes:${action.statusId}`, true));
case STATUS_QUOTES_FETCH_FAIL:
case STATUS_QUOTES_EXPAND_FAIL:
return create(state, draft => setLoading(draft, `quotes:${action.statusId}`, false));
case STATUS_QUOTES_FETCH_SUCCESS:
return create(state, draft => normalizeList(draft, `quotes:${action.statusId}`, action.statuses, action.next));
case STATUS_QUOTES_EXPAND_SUCCESS:
return create(state, draft => appendToList(draft, `quotes:${action.statusId}`, action.statuses, action.next));
case RECENT_EVENTS_FETCH_REQUEST:
return create(state, draft => setLoading(draft, 'recent_events', true));
case RECENT_EVENTS_FETCH_FAIL:

View file

@ -11,7 +11,7 @@ const useStatusQuotes = (statusId: string) => {
const { client } = usePlHooksApiClient();
return useInfiniteQuery({
queryKey: ['statusesLists', 'quotes', statusId],
queryKey: ['statusLists', 'quotes', statusId],
queryFn: ({ pageParam }) => pageParam.next?.() || client.statuses.getStatusQuotes(statusId).then(minifyStatusList),
initialPageParam: { previous: null, next: null, items: [], partial: false } as PaginatedResponse<string>,
getNextPageParam: (page) => page.next ? page : undefined,