Use react-query for episodes

This commit is contained in:
Mark McDowall 2025-11-27 22:00:51 -08:00
parent a97f2c016b
commit 1178c98341
21 changed files with 400 additions and 566 deletions

View file

@ -1,5 +1,4 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu'; import FilterMenu from 'Components/Menu/FilterMenu';
@ -12,12 +11,10 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager'; import TablePager from 'Components/Table/TablePager';
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector'; import useEpisodes from 'Episode/useEpisodes';
import { useCustomFiltersList } from 'Filters/useCustomFilters'; import { useCustomFiltersList } from 'Filters/useCustomFilters';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import { align, icons, kinds } from 'Helpers/Props'; import { align, icons, kinds } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections'; import { SortDirection } from 'Helpers/Props/sortDirections';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import HistoryItem from 'typings/History'; import HistoryItem from 'typings/History';
import { TableOptionsChangePayload } from 'typings/Table'; import { TableOptionsChangePayload } from 'typings/Table';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
@ -53,17 +50,22 @@ function History() {
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } = const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useHistoryOptions(); useHistoryOptions();
const episodeIds = useMemo(() => {
return selectUniqueIds<HistoryItem, number>(records, 'episodeId');
}, [records]);
const {
isFetching: isEpisodesFetching,
isFetched: isEpisodesFetched,
error: episodesError,
} = useEpisodes({ episodeIds });
const filters = useFilters(); const filters = useFilters();
const requestCurrentPage = useCurrentPage();
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
useSelector(createEpisodesFetchingSelector());
const customFilters = useCustomFiltersList('history'); const customFilters = useCustomFiltersList('history');
const dispatch = useDispatch();
const isFetchingAny = isLoading || isEpisodesFetching; const isFetchingAny = isLoading || isEpisodesFetching;
const isAllPopulated = isFetched && (isEpisodesPopulated || !records.length); const isAllPopulated = isFetched && (isEpisodesFetched || !records.length);
const hasError = error || episodesError; const hasError = error || episodesError;
const handleFilterSelect = useCallback( const handleFilterSelect = useCallback(
@ -99,25 +101,6 @@ function History() {
refetch(); refetch();
}, [goToPage, refetch]); }, [goToPage, refetch]);
useEffect(() => {
return () => {
dispatch(clearEpisodes());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const episodeIds = selectUniqueIds<HistoryItem, number>(
records,
'episodeId'
);
if (episodeIds.length) {
dispatch(fetchEpisodes({ episodeIds }));
} else {
dispatch(clearEpisodes());
}
}, [records, dispatch]);
useEffect(() => { useEffect(() => {
const repopulate = () => { const repopulate = () => {
refetch(); refetch();

View file

@ -22,12 +22,11 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager'; import TablePager from 'Components/Table/TablePager';
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector'; import useEpisodes from 'Episode/useEpisodes';
import { useCustomFiltersList } from 'Filters/useCustomFilters'; import { useCustomFiltersList } from 'Filters/useCustomFilters';
import { align, icons, kinds } from 'Helpers/Props'; import { align, icons, kinds } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections'; import { SortDirection } from 'Helpers/Props/sortDirections';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { CheckInputChanged } from 'typings/inputs'; import { CheckInputChanged } from 'typings/inputs';
import QueueModel from 'typings/Queue'; import QueueModel from 'typings/Queue';
@ -79,8 +78,17 @@ function QueueContent() {
const { isGrabbing, grabQueueItems } = useGrabQueueItems(); const { isGrabbing, grabQueueItems } = useGrabQueueItems();
const { count } = useQueueStatus(); const { count } = useQueueStatus();
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
useSelector(createEpisodesFetchingSelector()); const episodeIds = useMemo(() => {
return selectUniqueIds<QueueModel, number>(records, 'episodeIds');
}, [records]);
const {
isFetching: isEpisodesFetching,
isFetched: isEpisodesFetched,
error: episodesError,
} = useEpisodes({ episodeIds });
const customFilters = useCustomFiltersList('queue'); const customFilters = useCustomFiltersList('queue');
const isRefreshMonitoredDownloadsExecuting = useSelector( const isRefreshMonitoredDownloadsExecuting = useSelector(
@ -109,7 +117,7 @@ function QueueContent() {
// Use isLoading over isFetched to avoid losing the table UI when switching pages // Use isLoading over isFetched to avoid losing the table UI when switching pages
const isAllPopulated = const isAllPopulated =
!isLoading && !isLoading &&
(isEpisodesPopulated || (isEpisodesFetched ||
!records.length || !records.length ||
records.every((e) => !e.episodeIds?.length)); records.every((e) => !e.episodeIds?.length));
const hasError = error || episodesError; const hasError = error || episodesError;
@ -187,16 +195,6 @@ function QueueContent() {
[goToPage] [goToPage]
); );
useEffect(() => {
const episodeIds = selectUniqueIds(records, 'episodeIds');
if (episodeIds.length) {
dispatch(fetchEpisodes({ episodeIds }));
} else {
dispatch(clearEpisodes());
}
}, [records, dispatch]);
useEffect(() => { useEffect(() => {
const repopulate = () => { const repopulate = () => {
refetch(); refetch();

View file

@ -15,7 +15,7 @@ import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import EpisodeFormats from 'Episode/EpisodeFormats'; import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages'; import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality'; import EpisodeQuality from 'Episode/EpisodeQuality';
import useEpisodesWithIds from 'Episode/useEpisodesWithIds'; import { useEpisodesWithIds } from 'Episode/useEpisode';
import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import Language from 'Language/Language'; import Language from 'Language/Language';

View file

@ -1,4 +1,4 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router'; import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
import React from 'react'; import React from 'react';
import DocumentTitle from 'react-document-title'; import DocumentTitle from 'react-document-title';
@ -7,14 +7,13 @@ import { Store } from 'redux';
import Page from 'Components/Page/Page'; import Page from 'Components/Page/Page';
import ApplyTheme from './ApplyTheme'; import ApplyTheme from './ApplyTheme';
import AppRoutes from './AppRoutes'; import AppRoutes from './AppRoutes';
import { queryClient } from './queryClient';
interface AppProps { interface AppProps {
store: Store; store: Store;
history: ConnectedRouterProps['history']; history: ConnectedRouterProps['history'];
} }
const queryClient = new QueryClient();
function App({ store, history }: AppProps) { function App({ store, history }: AppProps) {
return ( return (
<DocumentTitle title={window.Sonarr.instanceName}> <DocumentTitle title={window.Sonarr.instanceName}>

View file

@ -2,7 +2,6 @@ import { Error } from './AppSectionState';
import BlocklistAppState from './BlocklistAppState'; import BlocklistAppState from './BlocklistAppState';
import CaptchaAppState from './CaptchaAppState'; import CaptchaAppState from './CaptchaAppState';
import CommandAppState from './CommandAppState'; import CommandAppState from './CommandAppState';
import EpisodesAppState from './EpisodesAppState';
import HistoryAppState, { SeriesHistoryAppState } from './HistoryAppState'; import HistoryAppState, { SeriesHistoryAppState } from './HistoryAppState';
import ImportSeriesAppState from './ImportSeriesAppState'; import ImportSeriesAppState from './ImportSeriesAppState';
import InteractiveImportAppState from './InteractiveImportAppState'; import InteractiveImportAppState from './InteractiveImportAppState';
@ -41,7 +40,6 @@ interface AppState {
captcha: CaptchaAppState; captcha: CaptchaAppState;
commands: CommandAppState; commands: CommandAppState;
episodeHistory: HistoryAppState; episodeHistory: HistoryAppState;
episodes: EpisodesAppState;
importSeries: ImportSeriesAppState; importSeries: ImportSeriesAppState;
interactiveImport: InteractiveImportAppState; interactiveImport: InteractiveImportAppState;
oAuth: OAuthAppState; oAuth: OAuthAppState;

View file

@ -1,9 +0,0 @@
import AppSectionState from 'App/State/AppSectionState';
import Column from 'Components/Table/Column';
import Episode from 'Episode/Episode';
interface EpisodesAppState extends AppSectionState<Episode> {
columns: Column[];
}
export default EpisodesAppState;

View file

@ -0,0 +1,3 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient();

View file

@ -150,13 +150,37 @@ function SignalRListener() {
} }
if (name === 'episode') { if (name === 'episode') {
if (version < 5) {
return;
}
if (body.action === 'updated') { if (body.action === 'updated') {
dispatch( const updatedItem = body.resource as Episode;
updateItem({
section: 'episodes', queryClient.setQueriesData(
updateOnly: true, { queryKey: ['/episode'] },
...body.resource, (oldData: Episode[] | undefined) => {
}) if (!oldData) {
return oldData;
}
const itemIndex = oldData.findIndex(
(item) => item.id === updatedItem.id
);
// Don't add episode if not found
if (itemIndex === -1) {
return oldData;
}
return oldData.map((item) => {
if (item.id === updatedItem.id) {
return updatedItem;
}
return item;
});
}
); );
} }

View file

@ -1,5 +1,4 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs'; import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody'; import ModalBody from 'Components/Modal/ModalBody';
@ -10,10 +9,13 @@ import MonitorToggleButton from 'Components/MonitorToggleButton';
import Episode from 'Episode/Episode'; import Episode from 'Episode/Episode';
import EpisodeDetailsTab from 'Episode/EpisodeDetailsTab'; import EpisodeDetailsTab from 'Episode/EpisodeDetailsTab';
import episodeEntities from 'Episode/episodeEntities'; import episodeEntities from 'Episode/episodeEntities';
import useEpisode, { EpisodeEntity } from 'Episode/useEpisode'; import useEpisode, {
EpisodeEntity,
getQueryKey,
useToggleEpisodesMonitored,
} from 'Episode/useEpisode';
import Series from 'Series/Series'; import Series from 'Series/Series';
import useSeries from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import EpisodeHistory from './History/EpisodeHistory'; import EpisodeHistory from './History/EpisodeHistory';
import EpisodeSearch from './Search/EpisodeSearch'; import EpisodeSearch from './Search/EpisodeSearch';
@ -28,7 +30,6 @@ export interface EpisodeDetailsModalContentProps {
episodeEntity: EpisodeEntity; episodeEntity: EpisodeEntity;
seriesId: number; seriesId: number;
episodeTitle: string; episodeTitle: string;
isSaving?: boolean;
showOpenSeriesButton?: boolean; showOpenSeriesButton?: boolean;
selectedTab?: EpisodeDetailsTab; selectedTab?: EpisodeDetailsTab;
startInteractiveSearch?: boolean; startInteractiveSearch?: boolean;
@ -36,22 +37,17 @@ export interface EpisodeDetailsModalContentProps {
onModalClose(): void; onModalClose(): void;
} }
function EpisodeDetailsModalContent(props: EpisodeDetailsModalContentProps) { function EpisodeDetailsModalContent({
const { episodeId,
episodeId, episodeEntity = episodeEntities.EPISODES,
episodeEntity = episodeEntities.EPISODES, seriesId,
seriesId, episodeTitle,
episodeTitle, showOpenSeriesButton = false,
isSaving = false, startInteractiveSearch = false,
showOpenSeriesButton = false, selectedTab = 'details',
startInteractiveSearch = false, onTabChange,
selectedTab = 'details', onModalClose,
onTabChange, }: EpisodeDetailsModalContentProps) {
onModalClose,
} = props;
const dispatch = useDispatch();
const [currentlySelectedTab, setCurrentlySelectedTab] = useState(selectedTab); const [currentlySelectedTab, setCurrentlySelectedTab] = useState(selectedTab);
const { const {
@ -70,6 +66,10 @@ function EpisodeDetailsModalContent(props: EpisodeDetailsModalContentProps) {
monitored, monitored,
} = useEpisode(episodeId, episodeEntity) as Episode; } = useEpisode(episodeId, episodeEntity) as Episode;
const { toggleEpisodesMonitored, isToggling } = useToggleEpisodesMonitored(
getQueryKey(episodeEntity)!
);
const handleTabSelect = useCallback( const handleTabSelect = useCallback(
(selectedIndex: number) => { (selectedIndex: number) => {
const tab = TABS[selectedIndex]; const tab = TABS[selectedIndex];
@ -81,15 +81,12 @@ function EpisodeDetailsModalContent(props: EpisodeDetailsModalContentProps) {
const handleMonitorEpisodePress = useCallback( const handleMonitorEpisodePress = useCallback(
(monitored: boolean) => { (monitored: boolean) => {
dispatch( toggleEpisodesMonitored({
toggleEpisodeMonitored({ episodeIds: [episodeId],
episodeEntity, monitored,
episodeId, });
monitored,
})
);
}, },
[episodeEntity, episodeId, dispatch] [episodeId, toggleEpisodesMonitored]
); );
const seriesLink = `/series/${titleSlug}`; const seriesLink = `/series/${titleSlug}`;
@ -101,7 +98,7 @@ function EpisodeDetailsModalContent(props: EpisodeDetailsModalContentProps) {
monitored={monitored} monitored={monitored}
size={18} size={18}
isDisabled={!seriesMonitored} isDisabled={!seriesMonitored}
isSaving={isSaving} isSaving={isToggling}
onPress={handleMonitorEpisodePress} onPress={handleMonitorEpisodePress}
/> />

View file

@ -1,17 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createEpisodesFetchingSelector() {
return createSelector(
(state: AppState) => state.episodes,
(episodes) => {
return {
isEpisodesFetching: episodes.isFetching,
isEpisodesPopulated: episodes.isPopulated,
episodesError: episodes.error,
};
}
);
}
export default createEpisodesFetchingSelector;

View file

@ -0,0 +1,149 @@
import { createElement } from 'react';
import Icon from 'Components/Icon';
import Column from 'Components/Table/Column';
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
import { icons } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import translate from 'Utilities/String/translate';
interface EpisodeSelectOptions {
sortKey: string;
sortDirection: SortDirection;
columns: Column[];
}
const { useOptions, useOption, setOptions, setOption, setSort } =
createOptionsStore<EpisodeSelectOptions>('episode_options', () => {
return {
sortKey: 'episodeNumber',
sortDirection: 'descending',
columns: [
{
name: 'monitored',
label: '',
columnLabel: () => translate('Monitored'),
isVisible: true,
isModifiable: false,
},
{
name: 'episodeNumber',
label: '#',
isVisible: true,
isSortable: true,
},
{
name: 'title',
label: () => translate('Title'),
isVisible: true,
isSortable: true,
},
{
name: 'path',
label: () => translate('Path'),
isVisible: false,
isSortable: true,
},
{
name: 'relativePath',
label: () => translate('RelativePath'),
isVisible: false,
isSortable: true,
},
{
name: 'airDateUtc',
label: () => translate('AirDate'),
isVisible: true,
isSortable: true,
},
{
name: 'runtime',
label: () => translate('Runtime'),
isVisible: false,
isSortable: true,
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: false,
},
{
name: 'audioInfo',
label: () => translate('AudioInfo'),
isVisible: false,
},
{
name: 'videoCodec',
label: () => translate('VideoCodec'),
isVisible: false,
},
{
name: 'videoDynamicRangeType',
label: () => translate('VideoDynamicRange'),
isVisible: false,
},
{
name: 'audioLanguages',
label: () => translate('AudioLanguages'),
isVisible: false,
},
{
name: 'subtitleLanguages',
label: () => translate('SubtitleLanguages'),
isVisible: false,
},
{
name: 'size',
label: () => translate('Size'),
isVisible: false,
isSortable: true,
},
{
name: 'releaseGroup',
label: () => translate('ReleaseGroup'),
isVisible: false,
},
{
name: 'customFormats',
label: () => translate('Formats'),
isVisible: false,
},
{
name: 'customFormatScore',
columnLabel: () => translate('CustomFormatScore'),
label: createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isVisible: false,
isSortable: true,
},
{
name: 'indexerFlags',
columnLabel: () => translate('IndexerFlags'),
label: createElement(Icon, {
name: icons.FLAG,
title: () => translate('IndexerFlags'),
}),
isVisible: false,
},
{
name: 'status',
label: () => translate('Status'),
isVisible: true,
},
{
name: 'actions',
label: '',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false,
},
],
};
});
export const useEpisodeOptions = useOptions;
export const setEpisodeOptions = setOptions;
export const useEpisodeOption = useOption;
export const setEpisodeOption = setOption;
export const setEpisodeSort = setSort;

View file

@ -1,8 +1,5 @@
import { QueryKey, useQueryClient } from '@tanstack/react-query'; import { QueryKey, useQueryClient } from '@tanstack/react-query';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { create } from 'zustand'; import { create } from 'zustand';
import AppState from 'App/State/AppState';
import useApiMutation from 'Helpers/Hooks/useApiMutation'; import useApiMutation from 'Helpers/Hooks/useApiMutation';
import { PagedQueryResponse } from 'Helpers/Hooks/usePagedApiQuery'; import { PagedQueryResponse } from 'Helpers/Hooks/usePagedApiQuery';
import { CalendarItem } from 'typings/Calendar'; import { CalendarItem } from 'typings/Calendar';
@ -17,39 +14,24 @@ export type EpisodeEntity =
interface EpisodeQueryKeyStore { interface EpisodeQueryKeyStore {
calendar: QueryKey | null; calendar: QueryKey | null;
episodes: QueryKey | null;
cutoffUnmet: QueryKey | null; cutoffUnmet: QueryKey | null;
missing: QueryKey | null; missing: QueryKey | null;
} }
const episodeQueryKeyStore = create<EpisodeQueryKeyStore>(() => ({ const episodeQueryKeyStore = create<EpisodeQueryKeyStore>(() => ({
calendar: null, calendar: null,
episodes: null,
cutoffUnmet: null, cutoffUnmet: null,
missing: null, missing: null,
})); }));
function createEpisodeSelector(episodeId?: number) {
return createSelector(
(state: AppState) => state.episodes.items,
(episodes) => {
return episodes.find(({ id }) => id === episodeId);
}
);
}
// No-op...ish
function createNoOpEpisodeSelector(_episodeId?: number) {
return createSelector(
(state: AppState) => state.episodes.items,
(_episodes) => {
return undefined;
}
);
}
export const getQueryKey = (episodeEntity: EpisodeEntity) => { export const getQueryKey = (episodeEntity: EpisodeEntity) => {
switch (episodeEntity) { switch (episodeEntity) {
case 'calendar': case 'calendar':
return episodeQueryKeyStore.getState().calendar; return episodeQueryKeyStore.getState().calendar;
case 'episodes':
return episodeQueryKeyStore.getState().episodes;
case 'wanted.cutoffUnmet': case 'wanted.cutoffUnmet':
return episodeQueryKeyStore.getState().cutoffUnmet; return episodeQueryKeyStore.getState().cutoffUnmet;
case 'wanted.missing': case 'wanted.missing':
@ -67,6 +49,9 @@ export const setEpisodeQueryKey = (
case 'calendar': case 'calendar':
episodeQueryKeyStore.setState({ calendar: queryKey }); episodeQueryKeyStore.setState({ calendar: queryKey });
break; break;
case 'episodes':
episodeQueryKeyStore.setState({ episodes: queryKey });
break;
case 'wanted.cutoffUnmet': case 'wanted.cutoffUnmet':
episodeQueryKeyStore.setState({ cutoffUnmet: queryKey }); episodeQueryKeyStore.setState({ cutoffUnmet: queryKey });
break; break;
@ -83,19 +68,6 @@ const useEpisode = (
episodeEntity: EpisodeEntity episodeEntity: EpisodeEntity
) => { ) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
let selector = createEpisodeSelector;
switch (episodeEntity) {
case 'calendar':
case 'wanted.cutoffUnmet':
case 'wanted.missing':
selector = createNoOpEpisodeSelector;
break;
default:
break;
}
const result = useSelector(selector(episodeId));
const queryKey = getQueryKey(episodeEntity); const queryKey = getQueryKey(episodeEntity);
if (episodeEntity === 'calendar') { if (episodeEntity === 'calendar') {
@ -104,7 +76,17 @@ const useEpisode = (
.getQueryData<CalendarItem[]>(queryKey) .getQueryData<CalendarItem[]>(queryKey)
?.find((e) => e.id === episodeId) ?.find((e) => e.id === episodeId)
: undefined; : undefined;
} else if ( }
if (episodeEntity === 'episodes') {
return queryKey
? queryClient
.getQueryData<Episode[]>(queryKey)
?.find((e) => e.id === episodeId)
: undefined;
}
if (
episodeEntity === 'wanted.cutoffUnmet' || episodeEntity === 'wanted.cutoffUnmet' ||
episodeEntity === 'wanted.missing' episodeEntity === 'wanted.missing'
) { ) {
@ -115,7 +97,7 @@ const useEpisode = (
: undefined; : undefined;
} }
return result; return undefined;
}; };
export default useEpisode; export default useEpisode;
@ -128,15 +110,33 @@ interface ToggleEpisodesMonitored {
export const useToggleEpisodesMonitored = (queryKey: QueryKey) => { export const useToggleEpisodesMonitored = (queryKey: QueryKey) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { mutate, isPending } = useApiMutation< const { mutate, isPending, variables } = useApiMutation<
unknown, unknown,
ToggleEpisodesMonitored ToggleEpisodesMonitored
>({ >({
path: '/episode/monitor', path: '/episode/monitor',
method: 'PUT', method: 'PUT',
mutationOptions: { mutationOptions: {
onSuccess: () => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey }); queryClient.setQueryData<Episode[] | undefined>(
queryKey,
(oldEpisodes) => {
if (!oldEpisodes) {
return oldEpisodes;
}
return oldEpisodes.map((oldEpisode) => {
if (variables.episodeIds.includes(oldEpisode.id)) {
return {
...oldEpisode,
monitored: variables.monitored,
};
}
return oldEpisode;
});
}
);
}, },
}, },
}); });
@ -144,5 +144,20 @@ export const useToggleEpisodesMonitored = (queryKey: QueryKey) => {
return { return {
toggleEpisodesMonitored: mutate, toggleEpisodesMonitored: mutate,
isToggling: isPending, isToggling: isPending,
togglingEpisodeIds: variables?.episodeIds ?? [],
togglingMonitored: variables?.monitored,
}; };
}; };
const DEFAULT_EPISODES: Episode[] = [];
export const useEpisodesWithIds = (episodeIds: number[]) => {
const queryClient = useQueryClient();
const queryKey = getQueryKey('episodes');
return queryKey
? queryClient
.getQueryData<Episode[]>(queryKey)
?.filter((e) => episodeIds.includes(e.id)) ?? DEFAULT_EPISODES
: DEFAULT_EPISODES;
};

View file

@ -1,5 +1,9 @@
import { useEffect, useMemo } from 'react';
import useApiQuery from 'Helpers/Hooks/useApiQuery'; import useApiQuery from 'Helpers/Hooks/useApiQuery';
import clientSideFilterAndSort from 'Utilities/Filter/clientSideFilterAndSort';
import Episode from './Episode'; import Episode from './Episode';
import { useEpisodeOptions } from './episodeOptionsStore';
import { setEpisodeQueryKey } from './useEpisode';
const DEFAULT_EPISODES: Episode[] = []; const DEFAULT_EPISODES: Episode[] = [];
@ -10,6 +14,7 @@ interface SeriesEpisodes {
interface SeasonEpisodes { interface SeasonEpisodes {
seriesId: number | undefined; seriesId: number | undefined;
seasonNumber: number | undefined; seasonNumber: number | undefined;
isSelection: boolean;
} }
interface EpisodeIds { interface EpisodeIds {
@ -27,9 +32,17 @@ export type EpisodeFilter =
| EpisodeFileId; | EpisodeFileId;
const useEpisodes = (params: EpisodeFilter) => { const useEpisodes = (params: EpisodeFilter) => {
const result = useApiQuery<Episode[]>({ const setQueryKey = !('isSelection' in params);
const { isPlaceholderData, queryKey, ...result } = useApiQuery<Episode[]>({
path: '/episode', path: '/episode',
queryParams: { ...params }, queryParams:
'isSelection' in params
? {
seriesId: params.seriesId,
seasonNumber: params.seasonNumber,
}
: { ...params },
queryOptions: { queryOptions: {
enabled: enabled:
('seriesId' in params && params.seriesId !== undefined) || ('seriesId' in params && params.seriesId !== undefined) ||
@ -38,10 +51,39 @@ const useEpisodes = (params: EpisodeFilter) => {
}, },
}); });
useEffect(() => {
if (setQueryKey && !isPlaceholderData) {
setEpisodeQueryKey('episodes', queryKey);
}
}, [setQueryKey, isPlaceholderData, queryKey]);
return { return {
...result, ...result,
queryKey,
data: result.data ?? DEFAULT_EPISODES, data: result.data ?? DEFAULT_EPISODES,
}; };
}; };
export default useEpisodes; export default useEpisodes;
export const useSeasonEpisodes = (seriesId: number, seasonNumber: number) => {
const { data, ...result } = useEpisodes({ seriesId });
const { sortKey, sortDirection } = useEpisodeOptions();
const seasonEpisodes = useMemo(() => {
const { data: seasonEpisodes } = clientSideFilterAndSort(
data.filter((episode) => episode.seasonNumber === seasonNumber),
{
sortKey,
sortDirection,
}
);
return seasonEpisodes;
}, [data, seasonNumber, sortKey, sortDirection]);
return {
...result,
data: seasonEpisodes,
};
};

View file

@ -1,29 +0,0 @@
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

@ -77,6 +77,7 @@ function SelectEpisodeModalContentInner(props: SelectEpisodeModalContentProps) {
const { isFetching, isFetched, data, error } = useEpisodes({ const { isFetching, isFetched, data, error } = useEpisodes({
seriesId, seriesId,
seasonNumber, seasonNumber,
isSelection: true,
}); });
const { sortKey, sortDirection } = useEpisodeSelectionOptions(); const { sortKey, sortDirection } = useEpisodeSelectionOptions();
@ -255,6 +256,7 @@ function SelectEpisodeModalContent(props: SelectEpisodeModalContentProps) {
const { data } = useEpisodes({ const { data } = useEpisodes({
seriesId: props.seriesId, seriesId: props.seriesId,
seasonNumber: props.seasonNumber, seasonNumber: props.seasonNumber,
isSelection: true,
}); });
return ( return (

View file

@ -1,8 +1,6 @@
import moment from 'moment'; import moment from 'moment';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import HeartRating from 'Components/HeartRating'; import HeartRating from 'Components/HeartRating';
@ -20,6 +18,7 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import Popover from 'Components/Tooltip/Popover'; import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip'; import Tooltip from 'Components/Tooltip/Tooltip';
import useEpisodes from 'Episode/useEpisodes';
import useEpisodeFiles from 'EpisodeFile/useEpisodeFiles'; import useEpisodeFiles from 'EpisodeFile/useEpisodeFiles';
import usePrevious from 'Helpers/Hooks/usePrevious'; import usePrevious from 'Helpers/Hooks/usePrevious';
import { import {
@ -43,7 +42,6 @@ import { getSeriesStatusDetails } from 'Series/SeriesStatus';
import useSeries from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName'; import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import { toggleSeriesMonitored } from 'Store/Actions/seriesActions'; import { toggleSeriesMonitored } from 'Store/Actions/seriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
@ -75,26 +73,6 @@ function getDateYear(date: string | undefined) {
return dateDate.format('YYYY'); return dateDate.format('YYYY');
} }
function createEpisodesSelector() {
return createSelector(
(state: AppState) => state.episodes,
(episodes) => {
const { items, isFetching, isPopulated, error } = episodes;
const hasEpisodes = !!items.length;
const hasMonitoredEpisodes = items.some((e) => e.monitored);
return {
isEpisodesFetching: isFetching,
isEpisodesPopulated: isPopulated,
episodesError: error,
hasEpisodes,
hasMonitoredEpisodes,
};
}
);
}
interface ExpandedState { interface ExpandedState {
allExpanded: boolean; allExpanded: boolean;
allCollapsed: boolean; allCollapsed: boolean;
@ -112,12 +90,19 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
const allSeries = useSelector(createAllSeriesSelector()); const allSeries = useSelector(createAllSeriesSelector());
const { const {
isEpisodesFetching, isFetching: isEpisodesFetching,
isEpisodesPopulated, isFetched: isEpisodesFetched,
episodesError, error: episodesError,
hasEpisodes, data,
hasMonitoredEpisodes, refetch: refetchEpisodes,
} = useSelector(createEpisodesSelector()); } = useEpisodes({ seriesId });
const { hasEpisodes, hasMonitoredEpisodes } = useMemo(() => {
return {
hasEpisodes: data.length > 0,
hasMonitoredEpisodes: data.some((e) => e.monitored),
};
}, [data]);
const { const {
isFetching: isEpisodeFilesFetching, isFetching: isEpisodeFilesFetching,
@ -358,8 +343,8 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
}, [seriesId, dispatch]); }, [seriesId, dispatch]);
const populate = useCallback(() => { const populate = useCallback(() => {
dispatch(fetchEpisodes({ seriesId })); refetchEpisodes();
}, [seriesId, dispatch]); }, [refetchEpisodes]);
useEffect(() => { useEffect(() => {
populate(); populate();
@ -370,7 +355,6 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
return () => { return () => {
unregisterPagePopulator(populate); unregisterPagePopulator(populate);
dispatch(clearEpisodes());
}; };
}, [populate, dispatch]); }, [populate, dispatch]);
@ -439,7 +423,7 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
const fanartUrl = getFanartUrl(images); const fanartUrl = getFanartUrl(images);
const isFetching = isEpisodesFetching || isEpisodeFilesFetching; const isFetching = isEpisodesFetching || isEpisodeFilesFetching;
const isPopulated = isEpisodesPopulated && isEpisodeFilesFetched; const isPopulated = isEpisodesFetched && isEpisodeFilesFetched;
return ( return (
<SeriesDetailsProvider seriesId={seriesId}> <SeriesDetailsProvider seriesId={seriesId}>

View file

@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import EpisodesAppState from 'App/State/EpisodesAppState';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Label from 'Components/Label'; import Label from 'Components/Label';
@ -18,6 +17,13 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import Popover from 'Components/Tooltip/Popover'; import Popover from 'Components/Tooltip/Popover';
import Episode from 'Episode/Episode'; import Episode from 'Episode/Episode';
import {
setEpisodeOptions,
setEpisodeSort,
useEpisodeOptions,
} from 'Episode/episodeOptionsStore';
import { getQueryKey, useToggleEpisodesMonitored } from 'Episode/useEpisode';
import { useSeasonEpisodes } from 'Episode/useEpisodes';
import usePrevious from 'Helpers/Hooks/usePrevious'; import usePrevious from 'Helpers/Hooks/usePrevious';
import { align, icons, sortDirections, tooltipPositions } from 'Helpers/Props'; import { align, icons, sortDirections, tooltipPositions } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections'; import { SortDirection } from 'Helpers/Props/sortDirections';
@ -27,13 +33,7 @@ import SeriesHistoryModal from 'Series/History/SeriesHistoryModal';
import SeasonInteractiveSearchModal from 'Series/Search/SeasonInteractiveSearchModal'; import SeasonInteractiveSearchModal from 'Series/Search/SeasonInteractiveSearchModal';
import { Statistics } from 'Series/Series'; import { Statistics } from 'Series/Series';
import useSeries from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import {
setEpisodesSort,
setEpisodesTableOption,
toggleEpisodesMonitored,
} from 'Store/Actions/episodeActions';
import { toggleSeasonMonitored } from 'Store/Actions/seriesActions'; import { toggleSeasonMonitored } from 'Store/Actions/seriesActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { TableOptionsChangePayload } from 'typings/Table'; import { TableOptionsChangePayload } from 'typings/Table';
@ -86,21 +86,6 @@ function getSeasonStatistics(episodes: Episode[]) {
}; };
} }
function createEpisodesSelector(seasonNumber: number) {
return createSelector(
createClientSideCollectionSelector('episodes'),
(episodes: EpisodesAppState) => {
const { items, columns, sortKey, sortDirection } = episodes;
const episodesInSeason = items.filter(
(episode) => episode.seasonNumber === seasonNumber
);
return { items: episodesInSeason, columns, sortKey, sortDirection };
}
);
}
function createIsSearchingSelector(seriesId: number, seasonNumber: number) { function createIsSearchingSelector(seriesId: number, seasonNumber: number) {
return createSelector(createCommandsSelector(), (commands) => { return createSelector(createCommandsSelector(), (commands) => {
return isCommandExecuting( return isCommandExecuting(
@ -134,10 +119,9 @@ function SeriesDetailsSeason({
}: SeriesDetailsSeasonProps) { }: SeriesDetailsSeasonProps) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { monitored: seriesMonitored, path } = useSeries(seriesId)!; const { monitored: seriesMonitored, path } = useSeries(seriesId)!;
const { data: items } = useSeasonEpisodes(seriesId, seasonNumber);
const { items, columns, sortKey, sortDirection } = useSelector( const { columns, sortKey, sortDirection } = useEpisodeOptions();
createEpisodesSelector(seasonNumber)
);
const { isSmallScreen } = useSelector(createDimensionsSelector()); const { isSmallScreen } = useSelector(createDimensionsSelector());
const isSearching = useSelector( const isSearching = useSelector(
@ -162,10 +146,11 @@ function SeriesDetailsSeason({
const [isInteractiveSearchModalOpen, setIsInteractiveSearchModalOpen] = const [isInteractiveSearchModalOpen, setIsInteractiveSearchModalOpen] =
useState(false); useState(false);
const lastToggledEpisode = useRef<number | null>(null); const { toggleEpisodesMonitored, isToggling, togglingEpisodeIds } =
const itemsRef = useRef(items); useToggleEpisodesMonitored(getQueryKey('episodes')!);
itemsRef.current = items; const lastToggledEpisode = useRef<number | null>(null);
const hasSetInitalExpand = useRef(false);
const seasonNumberTitle = const seasonNumberTitle =
seasonNumber === 0 seasonNumber === 0
@ -196,25 +181,23 @@ function SeriesDetailsSeason({
{ shiftKey }: { shiftKey: boolean } { shiftKey }: { shiftKey: boolean }
) => { ) => {
const lastToggled = lastToggledEpisode.current; const lastToggled = lastToggledEpisode.current;
const episodeIds = [episodeId]; const episodeIds = new Set([episodeId]);
if (shiftKey && lastToggled) { if (shiftKey && lastToggled) {
const { lower, upper } = getToggledRange(items, episodeId, lastToggled); const { lower, upper } = getToggledRange(items, episodeId, lastToggled);
for (let i = lower; i < upper; i++) { for (let i = lower; i < upper; i++) {
episodeIds.push(items[i].id); episodeIds.add(items[i].id);
} }
} }
lastToggledEpisode.current = episodeId; lastToggledEpisode.current = episodeId;
dispatch( toggleEpisodesMonitored({
toggleEpisodesMonitored({ episodeIds: Array.from(episodeIds),
episodeIds, monitored: value,
monitored: value, });
})
);
}, },
[items, dispatch] [items, toggleEpisodesMonitored]
); );
const handleSearchPress = useCallback(() => { const handleSearchPress = useCallback(() => {
@ -259,32 +242,36 @@ function SeriesDetailsSeason({
const handleSortPress = useCallback( const handleSortPress = useCallback(
(sortKey: string, sortDirection?: SortDirection) => { (sortKey: string, sortDirection?: SortDirection) => {
dispatch( setEpisodeSort({
setEpisodesSort({ sortKey,
sortKey, sortDirection,
sortDirection, });
})
);
}, },
[dispatch] []
); );
const handleTableOptionChange = useCallback( const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => { (payload: TableOptionsChangePayload) => {
dispatch(setEpisodesTableOption(payload)); setEpisodeOptions(payload);
}, },
[dispatch] []
); );
useEffect(() => { useEffect(() => {
if (hasSetInitalExpand.current || items.length === 0) {
return;
}
hasSetInitalExpand.current = true;
const expand = const expand =
itemsRef.current.some( items.some(
(item) => (item) =>
isAfter(item.airDateUtc) || isAfter(item.airDateUtc, { days: -30 }) isAfter(item.airDateUtc) || isAfter(item.airDateUtc, { days: -30 })
) || itemsRef.current.every((item) => !item.airDateUtc); ) || items.every((item) => !item.airDateUtc);
onExpandPress(seasonNumber, expand && seasonNumber > 0); onExpandPress(seasonNumber, expand && seasonNumber > 0);
}, [seriesId, seasonNumber, onExpandPress]); }, [items, seriesId, seasonNumber, onExpandPress]);
useEffect(() => { useEffect(() => {
if ((previousEpisodeFileCount ?? 0) > 0 && episodeFileCount === 0) { if ((previousEpisodeFileCount ?? 0) > 0 && episodeFileCount === 0) {
@ -505,6 +492,9 @@ function SeriesDetailsSeason({
key={item.id} key={item.id}
columns={columns} columns={columns}
{...item} {...item}
isSaving={
isToggling && togglingEpisodeIds.includes(item.id)
}
onMonitorEpisodePress={handleMonitorEpisodePress} onMonitorEpisodePress={handleMonitorEpisodePress}
/> />
); );

View file

@ -1,296 +0,0 @@
import _ from 'lodash';
import React from 'react';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import Icon from 'Components/Icon';
import episodeEntities from 'Episode/episodeEntities';
import { icons, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import translate from 'Utilities/String/translate';
import { updateItem } from './baseActions';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
//
// Variables
export const section = 'episodes';
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
sortKey: 'episodeNumber',
sortDirection: sortDirections.DESCENDING,
items: [],
columns: [
{
name: 'monitored',
columnLabel: () => translate('Monitored'),
isVisible: true,
isModifiable: false
},
{
name: 'episodeNumber',
label: '#',
isVisible: true,
isSortable: true
},
{
name: 'title',
label: () => translate('Title'),
isVisible: true,
isSortable: true
},
{
name: 'path',
label: () => translate('Path'),
isVisible: false,
isSortable: true
},
{
name: 'relativePath',
label: () => translate('RelativePath'),
isVisible: false,
isSortable: true
},
{
name: 'airDateUtc',
label: () => translate('AirDate'),
isVisible: true,
isSortable: true
},
{
name: 'runtime',
label: () => translate('Runtime'),
isVisible: false,
isSortable: true
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: false
},
{
name: 'audioInfo',
label: () => translate('AudioInfo'),
isVisible: false
},
{
name: 'videoCodec',
label: () => translate('VideoCodec'),
isVisible: false
},
{
name: 'videoDynamicRangeType',
label: () => translate('VideoDynamicRange'),
isVisible: false
},
{
name: 'audioLanguages',
label: () => translate('AudioLanguages'),
isVisible: false
},
{
name: 'subtitleLanguages',
label: () => translate('SubtitleLanguages'),
isVisible: false
},
{
name: 'size',
label: () => translate('Size'),
isVisible: false,
isSortable: true
},
{
name: 'releaseGroup',
label: () => translate('ReleaseGroup'),
isVisible: false
},
{
name: 'customFormats',
label: () => translate('Formats'),
isVisible: false
},
{
name: 'customFormatScore',
columnLabel: () => translate('CustomFormatScore'),
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
}),
isVisible: false,
isSortable: true
},
{
name: 'indexerFlags',
columnLabel: () => translate('IndexerFlags'),
label: React.createElement(Icon, {
name: icons.FLAG,
title: () => translate('IndexerFlags')
}),
isVisible: false
},
{
name: 'status',
label: () => translate('Status'),
isVisible: true
},
{
name: 'actions',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
]
};
export const persistState = [
'episodes.columns',
'episodes.sortDirection',
'episodes.sortKey'
];
//
// Actions Types
export const FETCH_EPISODES = 'episodes/fetchEpisodes';
export const SET_EPISODES_SORT = 'episodes/setEpisodesSort';
export const SET_EPISODES_TABLE_OPTION = 'episodes/setEpisodesTableOption';
export const CLEAR_EPISODES = 'episodes/clearEpisodes';
export const TOGGLE_EPISODE_MONITORED = 'episodes/toggleEpisodeMonitored';
export const TOGGLE_EPISODES_MONITORED = 'episodes/toggleEpisodesMonitored';
//
// Action Creators
export const fetchEpisodes = createThunk(FETCH_EPISODES);
export const setEpisodesSort = createAction(SET_EPISODES_SORT);
export const setEpisodesTableOption = createAction(SET_EPISODES_TABLE_OPTION);
export const clearEpisodes = createAction(CLEAR_EPISODES);
export const toggleEpisodeMonitored = createThunk(TOGGLE_EPISODE_MONITORED);
export const toggleEpisodesMonitored = createThunk(TOGGLE_EPISODES_MONITORED);
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_EPISODES]: createFetchHandler(section, '/episode'),
[TOGGLE_EPISODE_MONITORED]: function(getState, payload, dispatch) {
const {
episodeId: id,
episodeEntity = episodeEntities.EPISODES,
monitored
} = payload;
dispatch(updateItem({
id,
section: episodeEntity,
isSaving: true
}));
const promise = createAjaxRequest({
url: `/episode/${id}`,
method: 'PUT',
data: JSON.stringify({ monitored }),
dataType: 'json'
}).request;
promise.done((data) => {
dispatch(updateItem({
id,
section: episodeEntity,
isSaving: false,
monitored
}));
});
promise.fail((xhr) => {
dispatch(updateItem({
id,
section: episodeEntity,
isSaving: false
}));
});
},
[TOGGLE_EPISODES_MONITORED]: function(getState, payload, dispatch) {
const {
episodeIds,
episodeEntity = episodeEntities.EPISODES,
monitored
} = payload;
const episodeSection = _.last(episodeEntity.split('.'));
dispatch(batchActions(
episodeIds.map((episodeId) => {
return updateItem({
id: episodeId,
section: episodeSection,
isSaving: true
});
})
));
const promise = createAjaxRequest({
url: '/episode/monitor',
method: 'PUT',
data: JSON.stringify({ episodeIds, monitored }),
dataType: 'json'
}).request;
promise.done((data) => {
dispatch(batchActions(
episodeIds.map((episodeId) => {
return updateItem({
id: episodeId,
section: episodeSection,
isSaving: false,
monitored
});
})
));
});
promise.fail((xhr) => {
dispatch(batchActions(
episodeIds.map((episodeId) => {
return updateItem({
id: episodeId,
section: episodeSection,
isSaving: false
});
})
));
});
}
});
//
// Reducers
export const reducers = createHandleActions({
[SET_EPISODES_TABLE_OPTION]: createSetTableOptionReducer(section),
[CLEAR_EPISODES]: (state) => {
return Object.assign({}, state, {
isFetching: false,
isPopulated: false,
error: null,
items: []
});
},
[SET_EPISODES_SORT]: createSetClientSideCollectionSortReducer(section)
}, defaultState, section);

View file

@ -1,7 +1,6 @@
import * as app from './appActions'; import * as app from './appActions';
import * as captcha from './captchaActions'; import * as captcha from './captchaActions';
import * as commands from './commandActions'; import * as commands from './commandActions';
import * as episodes from './episodeActions';
import * as episodeHistory from './episodeHistoryActions'; import * as episodeHistory from './episodeHistoryActions';
import * as importSeries from './importSeriesActions'; import * as importSeries from './importSeriesActions';
import * as interactiveImportActions from './interactiveImportActions'; import * as interactiveImportActions from './interactiveImportActions';
@ -17,7 +16,6 @@ export default [
app, app,
captcha, captcha,
commands, commands,
episodes,
episodeHistory, episodeHistory,
importSeries, importSeries,
interactiveImportActions, interactiveImportActions,

View file

@ -1,6 +1,7 @@
import _ from 'lodash'; import _ from 'lodash';
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions'; import { batchActions } from 'redux-batched-actions';
import { queryClient } from 'App/queryClient';
import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props'; import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props';
import getFilterTypePredicate from 'Helpers/Props/getFilterTypePredicate'; import getFilterTypePredicate from 'Helpers/Props/getFilterTypePredicate';
import { createThunk, handleThunks } from 'Store/thunks'; import { createThunk, handleThunks } from 'Store/thunks';
@ -14,7 +15,6 @@ import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler'; import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import createSaveProviderHandler from './Creators/createSaveProviderHandler'; import createSaveProviderHandler from './Creators/createSaveProviderHandler';
import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer'; import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
import { fetchEpisodes } from './episodeActions';
// //
// Local // Local
@ -720,7 +720,7 @@ export const actionHandlers = handleThunks({
promise.done((data) => { promise.done((data) => {
if (shouldFetchEpisodesAfterUpdate) { if (shouldFetchEpisodesAfterUpdate) {
dispatch(fetchEpisodes({ seriesId: seriesIds[0] })); queryClient.invalidateQueries({ queryKey: ['/episode'] });
} }
dispatch(set({ dispatch(set({

View file

@ -1,6 +1,9 @@
import KeysMatching from 'typings/Helpers/KeysMatching'; import KeysMatching from 'typings/Helpers/KeysMatching';
function selectUniqueIds<T, K>(items: T[], idProp: KeysMatching<T, K>) { function selectUniqueIds<T, K>(
items: T[],
idProp: KeysMatching<T, K | K[]>
): K[] {
const result = items.reduce((acc: Set<K>, item) => { const result = items.reduce((acc: Set<K>, item) => {
if (!item[idProp]) { if (!item[idProp]) {
return acc; return acc;