pl-fe: migrate lists to tanstack query

Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
Nicole Mikołajczyk
2025-04-21 23:30:58 +02:00
parent 024160d00a
commit bfef2fadd7
12 changed files with 96 additions and 263 deletions

View File

@ -1,3 +1,4 @@
import { queryClient } from 'pl-fe/queries/client';
import { selectAccount } from 'pl-fe/selectors';
import toast from 'pl-fe/toast';
import { isLoggedIn } from 'pl-fe/utils/auth';
@ -9,25 +10,10 @@ import { importEntities } from './importer';
import type { Account, List, PaginatedResponse } from 'pl-api';
import type { AppDispatch, RootState } from 'pl-fe/store';
const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS' as const;
const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL' as const;
const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS' as const;
const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE' as const;
const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET' as const;
const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP' as const;
const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST' as const;
const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS' as const;
const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL' as const;
const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST' as const;
const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS' as const;
const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL' as const;
const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS' as const;
const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST' as const;
const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS' as const;
const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL' as const;
@ -47,59 +33,13 @@ const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST' as const
const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS' as const;
const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL' as const;
const fetchList = (listId: string) => (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
if (getState().lists[listId]) {
return;
}
return getClient(getState()).lists.getList(listId)
.then((data) => dispatch(fetchListSuccess(data)))
.catch(err => dispatch(fetchListFail(listId, err)));
};
const fetchListSuccess = (list: List) => ({
type: LIST_FETCH_SUCCESS,
list,
});
const fetchListFail = (listId: string, error: unknown) => ({
type: LIST_FETCH_FAIL,
listId,
error,
});
const fetchLists = () => (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
return getClient(getState()).lists.getLists()
.then((data) => dispatch(fetchListsSuccess(data)));
};
const fetchListsSuccess = (lists: Array<List>) => ({
type: LISTS_FETCH_SUCCESS,
lists,
});
const submitListEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => {
const listId = getState().listEditor.listId!;
const title = getState().listEditor.title;
if (listId === null) {
dispatch(createList(title, shouldReset));
} else {
dispatch(updateList(listId, title, shouldReset));
}
};
interface ListEditorSetupAction {
type: typeof LIST_EDITOR_SETUP;
list: List;
}
const setupListEditor = (listId: string) => (dispatch: AppDispatch, getState: () => RootState) => {
const list = getState().lists[listId];
const setupListEditor = (listId: string) => (dispatch: AppDispatch) => {
const list = queryClient.getQueryData<Array<List>>(['lists'])?.find((list) => list.id === listId);
if (!list) return;
dispatch<ListEditorSetupAction>({
@ -115,80 +55,10 @@ const changeListEditorTitle = (value: string) => ({
value,
});
const createList = (title: string, shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(createListRequest());
return getClient(getState()).lists.createList({ title }).then((data) => {
dispatch(createListSuccess(data));
if (shouldReset) {
dispatch(resetListEditor());
}
}).catch(err => dispatch(createListFail(err)));
};
const createListRequest = () => ({
type: LIST_CREATE_REQUEST,
});
const createListSuccess = (list: List) => ({
type: LIST_CREATE_SUCCESS,
list,
});
const createListFail = (error: unknown) => ({
type: LIST_CREATE_FAIL,
error,
});
const updateList = (listId: string, title: string, shouldReset?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
dispatch(updateListRequest(listId));
return getClient(getState()).lists.updateList(listId, { title }).then((data) => {
dispatch(updateListSuccess(data));
if (shouldReset) {
dispatch(resetListEditor());
}
}).catch(err => dispatch(updateListFail(listId, err)));
};
const updateListRequest = (listId: string) => ({
type: LIST_UPDATE_REQUEST,
listId,
});
const updateListSuccess = (list: List) => ({
type: LIST_UPDATE_SUCCESS,
list,
});
const updateListFail = (listId: string, error: unknown) => ({
type: LIST_UPDATE_FAIL,
listId,
error,
});
const resetListEditor = () => ({
type: LIST_EDITOR_RESET,
});
const deleteList = (listId: string) => (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
return getClient(getState()).lists.deleteList(listId)
.then(() => dispatch(deleteListSuccess(listId)));
};
const deleteListSuccess = (listId: string) => ({
type: LIST_DELETE_SUCCESS,
listId,
});
const fetchListAccounts = (listId: string) => (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
@ -293,7 +163,6 @@ const setupListAdder = (accountId: string) => (dispatch: AppDispatch, getState:
type: LIST_ADDER_SETUP,
account,
});
dispatch(fetchLists());
dispatch(fetchAccountLists(accountId));
};
@ -333,19 +202,9 @@ const removeFromListAdder = (listId: string) => (dispatch: AppDispatch, getState
};
type ListsAction =
| ReturnType<typeof fetchListSuccess>
| ReturnType<typeof fetchListFail>
| ReturnType<typeof fetchListsSuccess>
| ListEditorSetupAction
| ReturnType<typeof changeListEditorTitle>
| ReturnType<typeof createListRequest>
| ReturnType<typeof createListSuccess>
| ReturnType<typeof createListFail>
| ReturnType<typeof updateListRequest>
| ReturnType<typeof updateListSuccess>
| ReturnType<typeof updateListFail>
| ReturnType<typeof resetListEditor>
| ReturnType<typeof deleteListSuccess>
| ReturnType<typeof fetchListAccountsRequest>
| ReturnType<typeof fetchListAccountsSuccess>
| ReturnType<typeof fetchListAccountsFail>
@ -361,19 +220,9 @@ type ListsAction =
| ReturnType<typeof fetchAccountListsFail>;
export {
LIST_FETCH_SUCCESS,
LIST_FETCH_FAIL,
LISTS_FETCH_SUCCESS,
LIST_EDITOR_TITLE_CHANGE,
LIST_EDITOR_RESET,
LIST_EDITOR_SETUP,
LIST_CREATE_REQUEST,
LIST_CREATE_SUCCESS,
LIST_CREATE_FAIL,
LIST_UPDATE_REQUEST,
LIST_UPDATE_SUCCESS,
LIST_UPDATE_FAIL,
LIST_DELETE_SUCCESS,
LIST_ACCOUNTS_FETCH_REQUEST,
LIST_ACCOUNTS_FETCH_SUCCESS,
LIST_ACCOUNTS_FETCH_FAIL,
@ -387,13 +236,9 @@ export {
LIST_ADDER_LISTS_FETCH_REQUEST,
LIST_ADDER_LISTS_FETCH_SUCCESS,
LIST_ADDER_LISTS_FETCH_FAIL,
fetchList,
fetchLists,
submitListEditor,
setupListEditor,
changeListEditorTitle,
resetListEditor,
deleteList,
fetchListSuggestions,
clearListSuggestions,
changeListSuggestions,

View File

@ -1,15 +1,14 @@
import React, { useEffect, useMemo } from 'react';
import React, { useMemo } from 'react';
import { useIntl, defineMessages, IntlShape } from 'react-intl';
import { changeComposeFederated, changeComposeVisibility } from 'pl-fe/actions/compose';
import { fetchLists } from 'pl-fe/actions/lists';
import DropdownMenu, { MenuItem } from 'pl-fe/components/dropdown-menu';
import Button from 'pl-fe/components/ui/button';
import { getOrderedLists } from 'pl-fe/features/lists';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useCompose } from 'pl-fe/hooks/use-compose';
import { useFeatures } from 'pl-fe/hooks/use-features';
import { useLists } from 'pl-fe/queries/accounts/use-lists';
import type { Features } from 'pl-api';
@ -111,7 +110,7 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
const dispatch = useAppDispatch();
const compose = useCompose(composeId);
const lists = useAppSelector((state) => getOrderedLists(state));
const { data: lists = [] } = useLists(getOrderedLists);
const value = compose.privacy;
const unavailable = compose.id;
@ -131,10 +130,6 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
})),
}));
useEffect(() => {
if (features.addressableLists) dispatch(fetchLists());
}, []);
if (features.localOnlyStatuses) items.push({
icon: require('@tabler/icons/outline/affiliate.svg'),
text: intl.formatMessage(messages.local_short),

View File

@ -2,7 +2,6 @@ import React, { useEffect } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { useParams } from 'react-router-dom';
import { deleteList, fetchList } from 'pl-fe/actions/lists';
import { fetchListTimeline } from 'pl-fe/actions/timelines';
import { useListStream } from 'pl-fe/api/hooks/streaming/use-list-stream';
import DropdownMenu from 'pl-fe/components/dropdown-menu';
@ -11,9 +10,9 @@ import Button from 'pl-fe/components/ui/button';
import Column from 'pl-fe/components/ui/column';
import Spinner from 'pl-fe/components/ui/spinner';
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';
import { useDeleteList, useList } from 'pl-fe/queries/accounts/use-lists';
import { useModalsStore } from 'pl-fe/stores/modals';
import Timeline from '../ui/components/timeline';
@ -34,12 +33,12 @@ const ListTimeline: React.FC = () => {
const isMobile = useIsMobile();
const { openModal } = useModalsStore();
const list = useAppSelector((state) => state.lists[id]);
const { data: list, isFetching } = useList(id);
const { mutate: deleteList } = useDeleteList();
useListStream(id);
useEffect(() => {
dispatch(fetchList(id));
dispatch(fetchListTimeline(id));
}, [id]);
@ -59,14 +58,14 @@ const ListTimeline: React.FC = () => {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
dispatch(deleteList(id));
deleteList(id);
},
});
};
const title = list ? list.title : id;
if (typeof list === 'undefined') {
if (!list && isFetching) {
return (
<Column>
<div>
@ -74,7 +73,7 @@ const ListTimeline: React.FC = () => {
</div>
</Column>
);
} else if (list === false) {
} else if (!list) {
return (
<MissingIndicator />
);

View File

@ -1,13 +1,14 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changeListEditorTitle, submitListEditor } from 'pl-fe/actions/lists';
import { changeListEditorTitle } from 'pl-fe/actions/lists';
import Button from 'pl-fe/components/ui/button';
import Form from 'pl-fe/components/ui/form';
import HStack from 'pl-fe/components/ui/hstack';
import Input from 'pl-fe/components/ui/input';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useCreateList } from 'pl-fe/queries/accounts/use-lists';
const messages = defineMessages({
label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' },
@ -19,8 +20,9 @@ const NewListForm: React.FC = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const value = useAppSelector((state) => state.listEditor.title);
const disabled = useAppSelector((state) => !!state.listEditor.isSubmitting);
const { title: value, isSubmitting: disabled } = useAppSelector((state) => state.listEditor);
const { mutate: createList } = useCreateList();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch(changeListEditorTitle(e.target.value));
@ -28,7 +30,7 @@ const NewListForm: React.FC = () => {
const handleSubmit = (e: React.FormEvent<Element>) => {
e.preventDefault();
dispatch(submitListEditor(true));
createList({ title: value });
};
const label = intl.formatMessage(messages.label);

View File

@ -1,8 +1,6 @@
import React, { useEffect } from 'react';
import React from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { createSelector } from 'reselect';
import { fetchLists } from 'pl-fe/actions/lists';
import List, { ListItem } from 'pl-fe/components/list';
import Card from 'pl-fe/components/ui/card';
import Column from 'pl-fe/components/ui/column';
@ -10,36 +8,29 @@ import HStack from 'pl-fe/components/ui/hstack';
import Icon from 'pl-fe/components/ui/icon';
import Spinner from 'pl-fe/components/ui/spinner';
import Stack from 'pl-fe/components/ui/stack';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useLists } from 'pl-fe/queries/accounts/use-lists';
import NewListForm from './components/new-list-form';
import type { List as ListEntity } from 'pl-api';
import type { RootState } from 'pl-fe/store';
const messages = defineMessages({
heading: { id: 'column.lists', defaultMessage: 'Lists' },
subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' },
});
const getOrderedLists = createSelector([(state: RootState) => state.lists], lists => {
const getOrderedLists = (lists: Array<ListEntity>) => {
if (!lists) {
return lists;
}
return Object.values(lists).filter((item): item is ListEntity => !!item).sort((a, b) => a.title.localeCompare(b.title));
});
};
const Lists: React.FC = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const lists = useAppSelector((state) => getOrderedLists(state));
useEffect(() => {
dispatch(fetchLists());
}, []);
const { data: lists } = useLists(getOrderedLists);
if (!lists) {
return (

View File

@ -6,6 +6,7 @@ import Icon from 'pl-fe/components/icon';
import IconButton from 'pl-fe/components/icon-button';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useList } from 'pl-fe/queries/accounts/use-lists';
const messages = defineMessages({
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
@ -20,7 +21,7 @@ const List: React.FC<IList> = ({ listId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const list = useAppSelector((state) => state.lists[listId]);
const { data: list } = useList(listId);
const added = useAppSelector((state) => state.listAdder.lists.items.includes(listId));
const onRemove = () => dispatch(removeFromListAdder(listId));

View File

@ -8,7 +8,7 @@ import AccountContainer from 'pl-fe/containers/account-container';
import { getOrderedLists } from 'pl-fe/features/lists';
import NewListForm from 'pl-fe/features/lists/components/new-list-form';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useLists } from 'pl-fe/queries/accounts/use-lists';
import List from './components/list';
@ -27,7 +27,7 @@ const ListAdderModal: React.FC<BaseModalProps & ListAdderModalProps> = ({ accoun
const intl = useIntl();
const dispatch = useAppDispatch();
const listIds = useAppSelector((state) => getOrderedLists(state).map(list => list.id));
const { data: listIds = [] } = useLists((lists) => getOrderedLists(lists).map(list => list.id));
useEffect(() => {
dispatch(setupListAdder(accountId));

View File

@ -1,13 +1,14 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changeListEditorTitle, submitListEditor } from 'pl-fe/actions/lists';
import { changeListEditorTitle } from 'pl-fe/actions/lists';
import Button from 'pl-fe/components/ui/button';
import Form from 'pl-fe/components/ui/form';
import HStack from 'pl-fe/components/ui/hstack';
import Input from 'pl-fe/components/ui/input';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useUpdateList } from 'pl-fe/queries/accounts/use-lists';
const messages = defineMessages({
title: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
@ -18,8 +19,9 @@ const ListForm = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const value = useAppSelector((state) => state.listEditor.title);
const disabled = useAppSelector((state) => !state.listEditor.isChanged);
const { title: value, listId } = useAppSelector((state) => state.listEditor);
const { mutate: updateList, isPending: disabled } = useUpdateList(listId!);
const handleChange: React.ChangeEventHandler<HTMLInputElement> = e => {
dispatch(changeListEditorTitle(e.target.value));
@ -27,11 +29,11 @@ const ListForm = () => {
const handleSubmit: React.FormEventHandler<Element> = e => {
e.preventDefault();
dispatch(submitListEditor(false));
updateList({ title: value });
};
const handleClick = () => {
dispatch(submitListEditor(false));
updateList({ title: value });
};
const save = intl.formatMessage(messages.save);

View File

@ -0,0 +1,61 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { useClient } from 'pl-fe/hooks/use-client';
import { useFeatures } from 'pl-fe/hooks/use-features';
import { queryClient } from '../client';
import type { CreateListParams, List, UpdateListParams } from 'pl-api';
const useLists = <T>(
select?: ((data: Array<List>) => T),
) => {
const client = useClient();
const features = useFeatures();
return useQuery({
queryKey: ['lists'],
queryFn: () => client.lists.getLists(),
enabled: features.lists,
select,
});
};
const useList = (listId?: string) => useLists((data) => listId ? data.find(list => list.id === listId) : undefined);
const useCreateList = () => {
const client = useClient();
return useMutation({
mutationKey: ['lists', 'create'],
mutationFn: (params: CreateListParams) => client.lists.createList(params),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['lists'] }),
});
};
const useDeleteList = () => {
const client = useClient();
return useMutation({
mutationKey: ['lists', 'delete'],
mutationFn: (listId: string) => client.lists.deleteList(listId),
onSuccess: (_, deletedListId) => {
queryClient.setQueryData<Array<List>>(
['lists'],
(prevData) => prevData?.filter(({ id }) => id !== deletedListId),
);
},
});
};
const useUpdateList = (listId: string) => {
const client = useClient();
return useMutation({
mutationKey: ['lists', 'update', listId],
mutationFn: (params: UpdateListParams) => client.lists.updateList(listId, params),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['lists'] }),
});
};
export { useLists, useList, useCreateList, useDeleteList, useUpdateList };

View File

@ -17,7 +17,6 @@ import filters from './filters';
import instance from './instance';
import listAdder from './list-adder';
import listEditor from './list-editor';
import lists from './lists';
import me from './me';
import meta from './meta';
import notifications from './notifications';
@ -47,7 +46,6 @@ const reducers = {
instance,
listAdder,
listEditor,
lists,
me,
meta,
notifications,

View File

@ -1,12 +1,6 @@
import { create } from 'mutative';
import {
LIST_CREATE_REQUEST,
LIST_CREATE_FAIL,
LIST_CREATE_SUCCESS,
LIST_UPDATE_REQUEST,
LIST_UPDATE_FAIL,
LIST_UPDATE_SUCCESS,
LIST_EDITOR_RESET,
LIST_EDITOR_SETUP,
LIST_EDITOR_TITLE_CHANGE,
@ -24,7 +18,6 @@ import {
interface State {
listId: string | null;
isSubmitting: boolean;
isChanged: boolean;
title: string;
accounts: {
@ -42,7 +35,6 @@ interface State {
const initialState: State = {
listId: null,
isSubmitting: false,
isChanged: false,
title: '',
accounts: {
@ -70,24 +62,6 @@ const listEditorReducer = (state: State = initialState, action: ListsAction): St
case LIST_EDITOR_TITLE_CHANGE:
return create(state, (draft) => {
draft.title = action.value;
draft.isChanged = true;
});
case LIST_CREATE_REQUEST:
case LIST_UPDATE_REQUEST:
return create(state, (draft) => {
draft.isSubmitting = true;
draft.isChanged = false;
});
case LIST_CREATE_FAIL:
case LIST_UPDATE_FAIL:
return create(state, (draft) => {
draft.isSubmitting = false;
});
case LIST_CREATE_SUCCESS:
case LIST_UPDATE_SUCCESS:
return create(state, (draft) => {
draft.isSubmitting = false;
draft.listId = action.list.id;
});
case LIST_ACCOUNTS_FETCH_REQUEST:
return create(state, (draft) => {

View File

@ -1,46 +1,11 @@
import { create } from 'mutative';
import {
LIST_FETCH_SUCCESS,
LIST_FETCH_FAIL,
LISTS_FETCH_SUCCESS,
LIST_CREATE_SUCCESS,
LIST_UPDATE_SUCCESS,
LIST_DELETE_SUCCESS,
type ListsAction,
} from 'pl-fe/actions/lists';
import type { List } from 'pl-api';
type State = Record<string, List | false>;
const initialState: State = {};
const importList = (state: State, list: List) => {
state[list.id] = list;
};
const importLists = (state: State, lists: Array<List>) => {
lists.forEach(list => importList(state, list));
};
const lists = (state: State = initialState, action: ListsAction) => {
const lists = (state: State = initialState, action: any) => {
switch (action.type) {
case LIST_FETCH_SUCCESS:
case LIST_CREATE_SUCCESS:
case LIST_UPDATE_SUCCESS:
return create(state, (draft) => {
importList(draft, action.list);
});
case LISTS_FETCH_SUCCESS:
return create(state, (draft) => {
importLists(draft, action.lists);
});
case LIST_DELETE_SUCCESS:
case LIST_FETCH_FAIL:
return create(state, (draft) => {
draft[action.listId] = false;
});
default:
return state;
}