pl-fe: migrate polls to tanstack query

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2025-10-23 01:21:51 +02:00
parent 3a1d51d030
commit 3ac15a9fb6
11 changed files with 54 additions and 103 deletions

View File

@ -8,8 +8,9 @@ import { setComposeToStatus } from './compose';
import { STATUS_FETCH_SOURCE_FAIL, type StatusesAction } from './statuses';
import { deleteFromTimelines } from './timelines';
import type { PleromaConfig } from 'pl-api';
import type { PleromaConfig, Poll } from 'pl-api';
import type { AppDispatch, RootState } from 'pl-fe/store';
import { queryClient } from 'pl-fe/queries/client';
const ADMIN_CONFIG_FETCH_SUCCESS = 'ADMIN_CONFIG_FETCH_SUCCESS' as const;
@ -125,7 +126,7 @@ const redactStatus = (statusId: string) => (dispatch: AppDispatch, getState: ()
const state = getState();
const status = state.statuses[statusId]!;
const poll = status.poll_id ? state.polls[status.poll_id] : undefined;
const poll = status.poll_id ? queryClient.getQueryData<Poll>(['statuses', 'polls', status.poll_id]) : undefined;
return getClient(state).statuses.getStatusSource(statusId).then(response => {
dispatch(setComposeToStatus(status, poll, response.text, response.spoiler_text, response.content_type, false, undefined, undefined, true));

View File

@ -1,6 +1,7 @@
import { importEntities as importEntityStoreEntities } from 'pl-fe/entity-store/actions';
import { Entities } from 'pl-fe/entity-store/entities';
import { normalizeGroup } from 'pl-fe/normalizers/group';
import { queryClient } from 'pl-fe/queries/client';
import type { Account as BaseAccount, Group as BaseGroup, Poll as BasePoll, Relationship as BaseRelationship, Status as BaseStatus } from 'pl-api';
import type { AppDispatch, RootState } from 'pl-fe/store';
@ -113,7 +114,11 @@ const importEntities = (entities: {
if (!isEmpty(accounts)) dispatch(importEntityStoreEntities(Object.values(accounts), Entities.ACCOUNTS));
if (!isEmpty(groups)) dispatch(importEntityStoreEntities(Object.values(groups).map(normalizeGroup), Entities.GROUPS));
if (!isEmpty(polls)) dispatch<ImportPollAction>(({ type: POLLS_IMPORT, polls: Object.values(polls) }));
if (!isEmpty(polls)) {
for (const poll of Object.values(polls)) {
queryClient.setQueryData<BasePoll>(['statuses', 'polls', poll.id], poll);
}
}
if (!isEmpty(relationships)) dispatch(importEntityStoreEntities(Object.values(relationships), Entities.RELATIONSHIPS));
if (!isEmpty(statuses)) dispatch<ImportStatusesAction>({ type: STATUSES_IMPORT, statuses: Object.values(statuses) });
};

View File

@ -1,22 +0,0 @@
import { getClient } from '../api';
import { importEntities } from './importer';
import type { AppDispatch, RootState } from 'pl-fe/store';
const vote = (pollId: string, choices: number[]) =>
(dispatch: AppDispatch, getState: () => RootState) =>
getClient(getState()).polls.vote(pollId, choices).then((data) => {
dispatch(importEntities({ polls: [data] }));
});
const fetchPoll = (pollId: string) =>
(dispatch: AppDispatch, getState: () => RootState) =>
getClient(getState()).polls.getPoll(pollId).then((data) => {
dispatch(importEntities({ polls: [data] }));
});
export {
vote,
fetchPoll,
};

View File

@ -11,7 +11,7 @@ import { setComposeToStatus } from './compose';
import { importEntities } from './importer';
import { deleteFromTimelines } from './timelines';
import type { CreateStatusParams, Status as BaseStatus, ScheduledStatus, StatusSource } from 'pl-api';
import type { CreateStatusParams, Status as BaseStatus, ScheduledStatus, StatusSource, Poll } from 'pl-api';
import type { Status } from 'pl-fe/normalizers/status';
import type { AppDispatch, RootState } from 'pl-fe/store';
import type { IntlShape } from 'react-intl';
@ -91,7 +91,7 @@ const editStatus = (statusId: string) => (dispatch: AppDispatch, getState: () =>
const state = getState();
const status = state.statuses[statusId]!;
const poll = status.poll_id ? state.polls[status.poll_id] : undefined;
const poll = status.poll_id ? queryClient.getQueryData<Poll>(['statuses', 'polls', status.poll_id]) : undefined;
dispatch<StatusesAction>({ type: STATUS_FETCH_SOURCE_REQUEST });
@ -123,7 +123,7 @@ const deleteStatus = (statusId: string, groupId?: string, withRedraft = false) =
const state = getState();
const status = state.statuses[statusId]!;
const poll = status.poll_id ? state.polls[status.poll_id] : undefined;
const poll = status.poll_id ? queryClient.getQueryData<Poll>(['statuses', 'polls', status.poll_id]) : undefined;
dispatch<StatusesAction>({ type: STATUS_DELETE_REQUEST, params: status });

View File

@ -1,13 +1,12 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchPoll, vote } from 'pl-fe/actions/polls';
import Button from 'pl-fe/components/ui/button';
import HStack from 'pl-fe/components/ui/hstack';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import Tooltip from 'pl-fe/components/ui/tooltip';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { usePollQuery, usePollVoteMutation } from 'pl-fe/queries/statuses/use-poll';
import { useStatusMetaActions } from 'pl-fe/stores/status-meta';
import RelativeTimestamp from '../relative-timestamp';
@ -28,15 +27,17 @@ interface IPollFooter {
}
const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected, statusId }): JSX.Element => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { refetch } = usePollQuery(poll.id);
const { mutate: vote } = usePollVoteMutation(poll.id);
const { toggleShowPollResults } = useStatusMetaActions();
const handleVote = () => dispatch(vote(poll.id, Object.keys(selected) as any as number[]));
const handleVote = () => vote(Object.keys(selected) as any as number[]);
const handleRefresh: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(fetchPoll(poll.id));
refetch();
e.stopPropagation();
e.preventDefault();
};

View File

@ -1,11 +1,10 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { vote } from 'pl-fe/actions/polls';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { usePollQuery, usePollVoteMutation } from 'pl-fe/queries/statuses/use-poll';
import { useModalsActions } from 'pl-fe/stores/modals';
import { useStatusMeta } from 'pl-fe/stores/status-meta';
@ -25,10 +24,12 @@ interface IPoll {
const Poll: React.FC<IPoll> = ({ id, status, language, truncate }): JSX.Element | null => {
const { openModal } = useModalsActions();
const dispatch = useAppDispatch();
const isLoggedIn = useAppSelector((state) => state.me);
const poll = useAppSelector((state) => state.polls[id]);
const { data: poll } = usePollQuery(id);
// TODO: handle pending mutation state
const { mutate: vote } = usePollVoteMutation(id);
const { showPollResults } = useStatusMeta(status.id);
@ -40,8 +41,6 @@ const Poll: React.FC<IPoll> = ({ id, status, language, truncate }): JSX.Element
ap_id: status?.url,
});
const handleVote = (selectedId: number) => dispatch(vote(id, [selectedId]));
const toggleOption = (value: number) => {
if (isLoggedIn) {
if (poll?.multiple) {
@ -56,7 +55,7 @@ const Poll: React.FC<IPoll> = ({ id, status, language, truncate }): JSX.Element
const tmp: Selected = {};
tmp[value] = true;
setSelected(tmp);
handleVote(value);
vote([value]);
}
} else {
openUnauthorizedModal();

View File

@ -0,0 +1,29 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'pl-fe/hooks/use-client';
import type { Poll } from 'pl-api';
const usePollQuery = (pollId: string) => {
const client = useClient();
return useQuery<Poll>({
queryKey: ['statuses', 'polls', pollId],
queryFn: () => client.polls.getPoll(pollId),
});
};
const usePollVoteMutation = (pollId: string) => {
const client = useClient();
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['statuses', 'polls', pollId, 'vote'],
mutationFn: (choices: number[]) => client.polls.vote(pollId, choices),
onSuccess: (poll) => {
queryClient.setQueryData<Poll>(['statuses', 'polls', pollId], poll);
},
});
};
export { usePollQuery, usePollVoteMutation };

View File

@ -17,7 +17,6 @@ import meta from './meta';
import notifications from './notifications';
import pending_statuses from './pending-statuses';
import plfe from './pl-fe';
import polls from './polls';
import push_notifications from './push-notifications';
import statuses from './statuses';
import timelines from './timelines';
@ -37,7 +36,6 @@ const reducers = {
notifications,
pending_statuses,
plfe,
polls,
push_notifications,
statuses,
timelines,

View File

@ -1,38 +0,0 @@
import { Map as ImmutableMap } from 'immutable';
import { POLLS_IMPORT } from 'pl-fe/actions/importer';
import reducer from './polls';
describe('polls reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {} as any)).toEqual(ImmutableMap());
});
describe('POLLS_IMPORT', () => {
it('normalizes the poll', () => {
const polls = [{ id: '3', options: [{ title: 'Apples' }, { title: 'Oranges' }] }];
const action = { type: POLLS_IMPORT, polls };
const result = reducer(undefined, action);
const expected = {
'3': {
options: [
{ title: 'Apples', votes_count: 0 },
{ title: 'Oranges', votes_count: 0 },
],
emojis: [],
expired: false,
multiple: false,
voters_count: 0,
votes_count: 0,
own_votes: null,
voted: false,
},
};
expect(result.toJS()).toMatchObject(expected);
});
});
});

View File

@ -1,20 +0,0 @@
import { create } from 'mutative';
import { POLLS_IMPORT, type ImporterAction } from 'pl-fe/actions/importer';
import type { Poll } from 'pl-api';
type State = Record<string, Poll>;
const initialState: State = {};
const polls = (state: State = initialState, action: ImporterAction): State => {
switch (action.type) {
case POLLS_IMPORT:
return create(state, (draft) => action.polls.forEach(poll => draft[poll.id] = poll));
default:
return state;
}
};
export { polls as default };

View File

@ -131,14 +131,13 @@ const makeGetStatus = () => createSelector(
if (group) return state.entities[Entities.GROUPS]?.store[group] as Group;
return undefined;
},
(state: RootState, { id }: APIStatus) => state.polls[id] || null,
(_state: RootState, { username }: APIStatus) => username,
getFilters,
(state: RootState) => state.me,
(state: RootState) => state.auth.client.features,
],
(statusBase, statusReblog, statusQuote, statusGroup, poll, username, filters, me, features) => {
(statusBase, statusReblog, statusQuote, statusGroup, username, filters, me, features) => {
// const locale = getLocale('en');
if (!statusBase) return null;
@ -159,7 +158,6 @@ const makeGetStatus = () => createSelector(
reblog: statusReblog || null,
quote: statusQuote || null,
group: statusGroup || null,
poll,
filtered,
};
},