Use react-query for episode selection

This commit is contained in:
Mark McDowall 2025-11-27 20:04:26 -08:00
parent d252fa8ed6
commit b32f7e0c25
No known key found for this signature in database
9 changed files with 130 additions and 158 deletions

View file

@ -15,7 +15,7 @@ import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import useEpisodes from 'Episode/useEpisodes';
import useEpisodesWithIds from 'Episode/useEpisodesWithIds';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import Language from 'Language/Language';
@ -106,7 +106,7 @@ function QueueRow(props: QueueRowProps) {
} = props;
const series = useSeries(seriesId);
const episodes = useEpisodes(episodeIds);
const episodes = useEpisodesWithIds(episodeIds);
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);

View file

@ -42,7 +42,6 @@ interface AppState {
commands: CommandAppState;
episodeHistory: HistoryAppState;
episodes: EpisodesAppState;
episodesSelection: EpisodesAppState;
importSeries: ImportSeriesAppState;
interactiveImport: InteractiveImportAppState;
oAuth: OAuthAppState;

View file

@ -0,0 +1,21 @@
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
import { SortDirection } from 'Helpers/Props/sortDirections';
interface EpisodeSelectOptions {
sortKey: string;
sortDirection: SortDirection;
}
const { useOptions, useOption, setOptions, setOption, setSort } =
createOptionsStore<EpisodeSelectOptions>('episode_selection_options', () => {
return {
sortKey: 'episodeNumber',
sortDirection: 'ascending',
};
});
export const useEpisodeSelectionOptions = useOptions;
export const setEpisodeSelectionOptions = setOptions;
export const useEpisodeSelectionOption = useOption;
export const setEpisodeSelectionOption = setOption;
export const setEpisodeSelectionSort = setSort;

View file

@ -1,29 +1,47 @@
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import Episode from './Episode';
function getEpisodes(episodeIds: number[], episodes: Episode[]) {
return episodeIds.reduce<Episode[]>((acc, id) => {
const episode = episodes.find((episode) => episode.id === id);
const DEFAULT_EPISODES: Episode[] = [];
if (episode) {
acc.push(episode);
}
return acc;
}, []);
interface SeriesEpisodes {
seriesId: number;
}
function createEpisodeSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.episodes.items,
(episodes) => {
return getEpisodes(episodeIds, episodes);
}
);
interface SeasonEpisodes {
seriesId: number | undefined;
seasonNumber: number | undefined;
}
export default function useEpisodes(episodeIds: number[]) {
return useSelector(createEpisodeSelector(episodeIds));
interface EpisodeIds {
episodeIds: number[];
}
interface EpisodeFileId {
episodeFileId: number;
}
export type EpisodeFilter =
| SeriesEpisodes
| SeasonEpisodes
| EpisodeIds
| EpisodeFileId;
const useEpisodes = (params: EpisodeFilter) => {
const result = useApiQuery<Episode[]>({
path: '/episode',
queryParams: { ...params },
queryOptions: {
enabled:
('seriesId' in params && params.seriesId !== undefined) ||
('episodeIds' in params && params.episodeIds?.length > 0) ||
('episodeFileId' in params && params.episodeFileId !== undefined),
},
});
return {
...result,
data: result.data ?? DEFAULT_EPISODES,
};
};
export default useEpisodes;

View file

@ -0,0 +1,29 @@
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Episode from './Episode';
function getEpisodes(episodeIds: number[], episodes: Episode[]) {
return episodeIds.reduce<Episode[]>((acc, id) => {
const episode = episodes.find((episode) => episode.id === id);
if (episode) {
acc.push(episode);
}
return acc;
}, []);
}
function createEpisodeSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.episodes.items,
(episodes) => {
return getEpisodes(episodeIds, episodes);
}
);
}
export default function useEpisodesWithIds(episodeIds: number[]) {
return useSelector(createEpisodeSelector(episodeIds));
}

View file

@ -1,8 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import React, { useCallback, useMemo, useState } from 'react';
import { SelectProvider, useSelect } from 'App/Select/SelectContext';
import EpisodesAppState from 'App/State/EpisodesAppState';
import TextInput from 'Components/Form/TextInput';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@ -14,15 +11,15 @@ import Scroller from 'Components/Scroller/Scroller';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import Episode from 'Episode/Episode';
import {
setEpisodeSelectionSort,
useEpisodeSelectionOptions,
} from 'Episode/episodeSelectionOptionsStore';
import useEpisodes from 'Episode/useEpisodes';
import { kinds, scrollDirections } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import {
clearEpisodes,
fetchEpisodes,
setEpisodesSort,
} from 'Store/Actions/episodeSelectionActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { CheckInputChanged, InputChanged } from 'typings/inputs';
import clientSideFilterAndSort from 'Utilities/Filter/clientSideFilterAndSort';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import SelectEpisodeRow from './SelectEpisodeRow';
@ -47,15 +44,6 @@ const columns = [
},
];
function episodesSelector() {
return createSelector(
createClientSideCollectionSelector('episodeSelection'),
(episodes: EpisodesAppState) => {
return episodes;
}
);
}
export interface SelectedEpisode {
id: number;
episodes: Episode[];
@ -72,20 +60,7 @@ interface SelectEpisodeModalContentProps {
onModalClose(): unknown;
}
interface SelectEpisodeModalContentInnerProps {
selectedIds: number[] | string[];
seriesId?: number;
seasonNumber?: number;
selectedDetails?: string;
isAnime: boolean;
modalTitle: string;
onEpisodesSelect(selectedEpisodes: SelectedEpisode[]): unknown;
onModalClose(): unknown;
}
function SelectEpisodeModalContentInner(
props: SelectEpisodeModalContentInnerProps
) {
function SelectEpisodeModalContentInner(props: SelectEpisodeModalContentProps) {
const {
selectedIds,
seriesId,
@ -99,9 +74,13 @@ function SelectEpisodeModalContentInner(
const [filter, setFilter] = useState('');
const { isFetching, isPopulated, items, error, sortKey, sortDirection } =
useSelector(episodesSelector());
const dispatch = useDispatch();
const { isFetching, isFetched, data, error } = useEpisodes({
seriesId,
seasonNumber,
});
const { sortKey, sortDirection } = useEpisodeSelectionOptions();
const {
allSelected,
allUnselected,
@ -137,20 +116,18 @@ function SelectEpisodeModalContentInner(
const onSortPress = useCallback(
(newSortKey: string, newSortDirection?: SortDirection) => {
dispatch(
setEpisodesSort({
sortKey: newSortKey,
sortDirection: newSortDirection,
})
);
setEpisodeSelectionSort({
sortKey: newSortKey,
sortDirection: newSortDirection,
});
},
[dispatch]
[]
);
const onEpisodesSelectWrapper = useCallback(() => {
const episodeIds: number[] = getSelectedIds();
const selectedEpisodes = items.reduce((acc: Episode[], item) => {
const selectedEpisodes = data.reduce((acc: Episode[], item) => {
if (episodeIds.indexOf(item.id) > -1) {
acc.push(item);
}
@ -177,19 +154,7 @@ function SelectEpisodeModalContentInner(
});
onEpisodesSelect(mappedEpisodes);
}, [selectedIds, items, getSelectedIds, onEpisodesSelect]);
useEffect(
() => {
dispatch(fetchEpisodes({ seriesId, seasonNumber }));
return () => {
dispatch(clearEpisodes());
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
}, [selectedIds, data, getSelectedIds, onEpisodesSelect]);
let details = selectedDetails;
@ -200,6 +165,13 @@ function SelectEpisodeModalContentInner(
: translate('CountSelectedFile', { selectedCount });
}
const { data: items } = useMemo(() => {
return clientSideFilterAndSort<Episode>(data, {
sortKey,
sortDirection,
});
}, [data, sortKey, sortDirection]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
@ -224,7 +196,7 @@ function SelectEpisodeModalContentInner(
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !!items.length ? (
{isFetched && !!items.length ? (
<Table
columns={columns}
selectAll={true}
@ -254,7 +226,7 @@ function SelectEpisodeModalContentInner(
</Table>
) : null}
{isPopulated && !items.length
{isFetched && !data.length
? translate('NoEpisodesFoundForSelectedSeason')
: null}
</Scroller>
@ -280,10 +252,13 @@ function SelectEpisodeModalContentInner(
}
function SelectEpisodeModalContent(props: SelectEpisodeModalContentProps) {
const { items } = useSelector(episodesSelector());
const { data } = useEpisodes({
seriesId: props.seriesId,
seasonNumber: props.seasonNumber,
});
return (
<SelectProvider items={items}>
<SelectProvider items={data}>
<SelectEpisodeModalContentInner {...props} />
</SelectProvider>
);

View file

@ -1,68 +0,0 @@
import { createAction } from 'redux-actions';
import { sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import updateSectionState from 'Utilities/State/updateSectionState';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
//
// Variables
export const section = 'episodeSelection';
//
// State
export const defaultState = {
isFetching: false,
isReprocessing: false,
isPopulated: false,
error: null,
sortKey: 'episodeNumber',
sortDirection: sortDirections.ASCENDING,
items: []
};
export const persistState = [
'episodeSelection.sortKey',
'episodeSelection.sortDirection'
];
//
// Actions Types
export const FETCH_EPISODES = 'episodeSelection/fetchEpisodes';
export const SET_EPISODES_SORT = 'episodeSelection/setEpisodesSort';
export const CLEAR_EPISODES = 'episodeSelection/clearEpisodes';
//
// Action Creators
export const fetchEpisodes = createThunk(FETCH_EPISODES);
export const setEpisodesSort = createAction(SET_EPISODES_SORT);
export const clearEpisodes = createAction(CLEAR_EPISODES);
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_EPISODES]: createFetchHandler(section, '/episode')
});
//
// Reducers
export const reducers = createHandleActions({
[SET_EPISODES_SORT]: createSetClientSideCollectionSortReducer(section),
[CLEAR_EPISODES]: (state) => {
return updateSectionState(state, section, {
...defaultState,
sortKey: state.sortKey,
sortDirection: state.sortDirection
});
}
}, defaultState, section);

View file

@ -3,7 +3,6 @@ import * as captcha from './captchaActions';
import * as commands from './commandActions';
import * as episodes from './episodeActions';
import * as episodeHistory from './episodeHistoryActions';
import * as episodeSelection from './episodeSelectionActions';
import * as importSeries from './importSeriesActions';
import * as interactiveImportActions from './interactiveImportActions';
import * as oAuth from './oAuthActions';
@ -20,7 +19,6 @@ export default [
commands,
episodes,
episodeHistory,
episodeSelection,
importSeries,
interactiveImportActions,
oAuth,

View file

@ -128,14 +128,14 @@ const sort = <T extends ModelBase, TFilter = null, TSort = null>(
};
interface ClientSideFilterAndSortOptions<T extends ModelBase, TFilter, TSort> {
selectedFilterKey: string | number;
filters: Filter[];
selectedFilterKey?: string | number;
filters?: Filter[];
filterPredicates?: Record<
keyof TFilter,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(item: T, value: any, type: FilterType) => boolean
>;
customFilters: CustomFilter[];
customFilters?: CustomFilter[];
sortKey: string;
sortDirection: SortDirection;
secondarySortKey?: string;