1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-03-05 13:20:20 -05:00

Use react-query for wanted missing and cutoff unmet

This commit is contained in:
Mark McDowall
2025-11-14 16:31:59 -08:00
parent 5a702dec12
commit 4071278183
16 changed files with 512 additions and 613 deletions

View File

@@ -21,7 +21,6 @@ import RootFolderAppState from './RootFolderAppState';
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
import SettingsAppState from './SettingsAppState';
import TagsAppState from './TagsAppState';
import WantedAppState from './WantedAppState';
export interface FilterBuilderPropOption {
id: string;
@@ -99,7 +98,6 @@ interface AppState {
seriesIndex: SeriesIndexAppState;
settings: SettingsAppState;
tags: TagsAppState;
wanted: WantedAppState;
}
export default AppState;

View File

@@ -1,29 +0,0 @@
import AppSectionState, {
AppSectionFilterState,
PagedAppSectionState,
TableAppSectionState,
} from 'App/State/AppSectionState';
import Episode from 'Episode/Episode';
interface WantedEpisode extends Episode {
isSaving?: boolean;
}
interface WantedCutoffUnmetAppState
extends AppSectionState<WantedEpisode>,
AppSectionFilterState<WantedEpisode>,
PagedAppSectionState,
TableAppSectionState {}
interface WantedMissingAppState
extends AppSectionState<WantedEpisode>,
AppSectionFilterState<WantedEpisode>,
PagedAppSectionState,
TableAppSectionState {}
interface WantedAppState {
cutoffUnmet: WantedCutoffUnmetAppState;
missing: WantedMissingAppState;
}
export default WantedAppState;

View File

@@ -3,11 +3,13 @@ import {
HubConnectionBuilder,
LogLevel,
} from '@microsoft/signalr';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKey, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { useDispatch } from 'react-redux';
import ModelBase from 'App/ModelBase';
import Command from 'Commands/Command';
import Episode from 'Episode/Episode';
import { PagedQueryResponse } from 'Helpers/Hooks/usePagedApiQuery';
import { setAppValue, setVersion } from 'Store/Actions/appActions';
import { removeItem, updateItem } from 'Store/Actions/baseActions';
import {
@@ -315,30 +317,30 @@ function SignalRListener() {
}
if (name === 'wanted/cutoff') {
if (body.action === 'updated') {
dispatch(
updateItem({
section: 'wanted.cutoffUnmet',
updateOnly: true,
...body.resource,
})
);
if (version < 5 || body.action !== 'updated') {
return;
}
updatePagedItem<Episode>(
queryClient,
['/wanted/cutoff'],
body.resource as Episode
);
return;
}
if (name === 'wanted/missing') {
if (body.action === 'updated') {
dispatch(
updateItem({
section: 'wanted.missing',
updateOnly: true,
...body.resource,
})
);
if (version < 5 || body.action !== 'updated') {
return;
}
updatePagedItem<Episode>(
queryClient,
['/wanted/missing'],
body.resource as Episode
);
return;
}
@@ -385,3 +387,37 @@ function SignalRListener() {
}
export default SignalRListener;
const updatePagedItem = <T extends ModelBase>(
queryClient: ReturnType<typeof useQueryClient>,
queryKey: QueryKey,
updatedItem: T
) => {
queryClient.setQueriesData(
{ queryKey },
(oldData: PagedQueryResponse<T> | undefined) => {
if (!oldData) {
return oldData;
}
const itemIndex = oldData.records.findIndex(
(item) => item.id === updatedItem.id
);
if (itemIndex === -1) {
return oldData;
}
return {
...oldData,
records: oldData.records.map((item) => {
if (item.id === updatedItem.id) {
return updatedItem;
}
return item;
}),
};
}
);
};

View File

@@ -3,7 +3,6 @@ import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvi
import QueueDetails from 'Activity/Queue/QueueDetails';
import Icon from 'Components/Icon';
import ProgressBar from 'Components/ProgressBar';
import Episode from 'Episode/Episode';
import useEpisode, { EpisodeEntity } from 'Episode/useEpisode';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds, sizes } from 'Helpers/Props';
@@ -23,19 +22,19 @@ function EpisodeStatus({
episodeEntity = 'episodes',
episodeFileId,
}: EpisodeStatusProps) {
const {
airDateUtc,
monitored,
grabbed = false,
} = useEpisode(episodeId, episodeEntity) as Episode;
const episode = useEpisode(episodeId, episodeEntity);
const queueItem = useQueueItemForEpisode(episodeId);
const episodeFile = useEpisodeFile(episodeFileId);
const { airDateUtc, grabbed, monitored } = episode || {};
const hasEpisodeFile = !!episodeFile;
const isQueued = !!queueItem;
const hasAired = isBefore(airDateUtc);
if (!episode) {
return null;
}
if (isQueued) {
const { sizeLeft, size } = queueItem;

View File

@@ -3,7 +3,10 @@ import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { create } from 'zustand';
import AppState from 'App/State/AppState';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import { PagedQueryResponse } from 'Helpers/Hooks/usePagedApiQuery';
import { CalendarItem } from 'typings/Calendar';
import Episode from './Episode';
export type EpisodeEntity =
| 'calendar'
@@ -14,10 +17,14 @@ export type EpisodeEntity =
interface EpisodeQueryKeyStore {
calendar: QueryKey | null;
cutoffUnmet: QueryKey | null;
missing: QueryKey | null;
}
const episodeQueryKeyStore = create<EpisodeQueryKeyStore>(() => ({
calendar: null,
cutoffUnmet: null,
missing: null,
}));
function createEpisodeSelector(episodeId?: number) {
@@ -30,7 +37,7 @@ function createEpisodeSelector(episodeId?: number) {
}
// No-op...ish
function createCalendarEpisodeSelector(_episodeId?: number) {
function createNoOpEpisodeSelector(_episodeId?: number) {
return createSelector(
(state: AppState) => state.episodes.items,
(_episodes) => {
@@ -39,23 +46,18 @@ function createCalendarEpisodeSelector(_episodeId?: number) {
);
}
function createWantedCutoffUnmetEpisodeSelector(episodeId?: number) {
return createSelector(
(state: AppState) => state.wanted.cutoffUnmet.items,
(episodes) => {
return episodes.find(({ id }) => id === episodeId);
}
);
}
function createWantedMissingEpisodeSelector(episodeId?: number) {
return createSelector(
(state: AppState) => state.wanted.missing.items,
(episodes) => {
return episodes.find(({ id }) => id === episodeId);
}
);
}
const getQueryKey = (episodeEntity: EpisodeEntity) => {
switch (episodeEntity) {
case 'calendar':
return episodeQueryKeyStore.getState().calendar;
case 'wanted.cutoffUnmet':
return episodeQueryKeyStore.getState().cutoffUnmet;
case 'wanted.missing':
return episodeQueryKeyStore.getState().missing;
default:
return null;
}
};
export const setEpisodeQueryKey = (
episodeEntity: EpisodeEntity,
@@ -65,6 +67,12 @@ export const setEpisodeQueryKey = (
case 'calendar':
episodeQueryKeyStore.setState({ calendar: queryKey });
break;
case 'wanted.cutoffUnmet':
episodeQueryKeyStore.setState({ cutoffUnmet: queryKey });
break;
case 'wanted.missing':
episodeQueryKeyStore.setState({ missing: queryKey });
break;
default:
break;
}
@@ -79,31 +87,62 @@ const useEpisode = (
switch (episodeEntity) {
case 'calendar':
selector = createCalendarEpisodeSelector;
break;
case 'wanted.cutoffUnmet':
selector = createWantedCutoffUnmetEpisodeSelector;
break;
case 'wanted.missing':
selector = createWantedMissingEpisodeSelector;
selector = createNoOpEpisodeSelector;
break;
default:
break;
}
const result = useSelector(selector(episodeId));
const queryKey = getQueryKey(episodeEntity);
if (episodeEntity === 'calendar') {
const queryKey = episodeQueryKeyStore((state) => state.calendar);
return queryKey
? queryClient
.getQueryData<CalendarItem[]>(queryKey)
?.find((e) => e.id === episodeId)
: undefined;
} else if (
episodeEntity === 'wanted.cutoffUnmet' ||
episodeEntity === 'wanted.missing'
) {
return queryKey
? queryClient
.getQueryData<PagedQueryResponse<Episode>>(queryKey)
?.records?.find((e) => e.id === episodeId)
: undefined;
}
return result;
};
export default useEpisode;
interface ToggleEpisodesMonitored {
episodeIds: number[];
monitored: boolean;
}
export const useToggleEpisodesMonitored = (queryKey: QueryKey) => {
const queryClient = useQueryClient();
const { mutate, isPending } = useApiMutation<
unknown,
ToggleEpisodesMonitored
>({
path: '/episode/monitor',
method: 'PUT',
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
},
},
});
return {
toggleEpisodesMonitored: mutate,
isToggling: isPending,
};
};

View File

@@ -4,15 +4,19 @@ import { create } from 'zustand';
interface PageStore {
blocklist: number;
cutoffUnmet: number;
events: number;
history: number;
missing: number;
queue: number;
}
const pageStore = create<PageStore>(() => ({
blocklist: 1,
cutoffUnmet: 1,
events: 1,
history: 1,
missing: 1,
queue: 1,
}));

View File

@@ -15,7 +15,7 @@ interface PagedQueryOptions<T> extends QueryOptions<PagedQueryResponse<T>> {
filters?: PropertyFilter[];
}
interface PagedQueryResponse<T> {
export interface PagedQueryResponse<T> {
page: number;
pageSize: number;
sortKey: string;
@@ -94,6 +94,7 @@ const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
return {
...query,
queryKey,
records: data?.records ?? DEFAULT_RECORDS,
totalRecords: data?.totalRecords ?? 0,
totalPages: data?.totalPages ?? 0,

View File

@@ -210,7 +210,7 @@ function SeriesDetailsSeason({
dispatch(
toggleEpisodesMonitored({
episodeIds,
value,
monitored: value,
})
);
},

View File

@@ -19,7 +19,6 @@ import * as seriesHistory from './seriesHistoryActions';
import * as seriesIndex from './seriesIndexActions';
import * as settings from './settingsActions';
import * as tags from './tagActions';
import * as wanted from './wantedActions';
export default [
app,
@@ -42,6 +41,5 @@ export default [
seriesHistory,
seriesIndex,
settings,
tags,
wanted
tags
];

View File

@@ -1,330 +0,0 @@
import { createAction } from 'redux-actions';
import { filterTypes, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import serverSideCollectionHandlers from 'Utilities/State/serverSideCollectionHandlers';
import translate from 'Utilities/String/translate';
import createBatchToggleEpisodeMonitoredHandler from './Creators/createBatchToggleEpisodeMonitoredHandler';
import createHandleActions from './Creators/createHandleActions';
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
import createClearReducer from './Creators/Reducers/createClearReducer';
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
//
// Variables
export const section = 'wanted';
//
// State
export const defaultState = {
missing: {
isFetching: false,
isPopulated: false,
pageSize: 20,
sortKey: 'episodes.airDateUtc',
sortDirection: sortDirections.DESCENDING,
error: null,
items: [],
columns: [
{
name: 'series.sortTitle',
label: () => translate('SeriesTitle'),
isSortable: true,
isVisible: true
},
{
name: 'episode',
label: () => translate('Episode'),
isVisible: true
},
{
name: 'episodes.title',
label: () => translate('EpisodeTitle'),
isVisible: true
},
{
name: 'episodes.airDateUtc',
label: () => translate('AirDate'),
isSortable: true,
isVisible: true
},
{
name: 'episodes.lastSearchTime',
label: () => translate('LastSearched'),
isSortable: true,
isVisible: false
},
{
name: 'status',
label: () => translate('Status'),
isVisible: true
},
{
name: 'actions',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
],
selectedFilterKey: 'monitored',
filters: [
{
key: 'monitored',
label: () => translate('Monitored'),
filters: [
{
key: 'monitored',
value: true,
type: filterTypes.EQUAL
}
]
},
{
key: 'unmonitored',
label: () => translate('Unmonitored'),
filters: [
{
key: 'monitored',
value: false,
type: filterTypes.EQUAL
}
]
}
]
},
cutoffUnmet: {
isFetching: false,
isPopulated: false,
pageSize: 20,
sortKey: 'episodes.airDateUtc',
sortDirection: sortDirections.DESCENDING,
items: [],
columns: [
{
name: 'series.sortTitle',
label: () => translate('SeriesTitle'),
isSortable: true,
isVisible: true
},
{
name: 'episode',
label: () => translate('Episode'),
isVisible: true
},
{
name: 'episodes.title',
label: () => translate('EpisodeTitle'),
isVisible: true
},
{
name: 'episodes.airDateUtc',
label: () => translate('AirDate'),
isSortable: true,
isVisible: true
},
{
name: 'episodes.lastSearchTime',
label: () => translate('LastSearched'),
isSortable: true,
isVisible: false
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: false
},
{
name: 'status',
label: () => translate('Status'),
isVisible: true
},
{
name: 'actions',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
],
selectedFilterKey: 'monitored',
filters: [
{
key: 'monitored',
label: () => translate('Monitored'),
filters: [
{
key: 'monitored',
value: true,
type: filterTypes.EQUAL
}
]
},
{
key: 'unmonitored',
label: () => translate('Unmonitored'),
filters: [
{
key: 'monitored',
value: false,
type: filterTypes.EQUAL
}
]
}
]
}
};
export const persistState = [
'wanted.missing.pageSize',
'wanted.missing.sortKey',
'wanted.missing.sortDirection',
'wanted.missing.selectedFilterKey',
'wanted.missing.columns',
'wanted.cutoffUnmet.pageSize',
'wanted.cutoffUnmet.sortKey',
'wanted.cutoffUnmet.sortDirection',
'wanted.cutoffUnmet.selectedFilterKey',
'wanted.cutoffUnmet.columns'
];
//
// Actions Types
export const FETCH_MISSING = 'wanted/missing/fetchMissing';
export const GOTO_FIRST_MISSING_PAGE = 'wanted/missing/gotoMissingFirstPage';
export const GOTO_PREVIOUS_MISSING_PAGE = 'wanted/missing/gotoMissingPreviousPage';
export const GOTO_NEXT_MISSING_PAGE = 'wanted/missing/gotoMissingNextPage';
export const GOTO_LAST_MISSING_PAGE = 'wanted/missing/gotoMissingLastPage';
export const GOTO_MISSING_PAGE = 'wanted/missing/gotoMissingPage';
export const SET_MISSING_SORT = 'wanted/missing/setMissingSort';
export const SET_MISSING_FILTER = 'wanted/missing/setMissingFilter';
export const SET_MISSING_TABLE_OPTION = 'wanted/missing/setMissingTableOption';
export const CLEAR_MISSING = 'wanted/missing/clearMissing';
export const BATCH_TOGGLE_MISSING_EPISODES = 'wanted/missing/batchToggleMissingEpisodes';
export const FETCH_CUTOFF_UNMET = 'wanted/cutoffUnmet/fetchCutoffUnmet';
export const GOTO_FIRST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFirstPage';
export const GOTO_PREVIOUS_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPreviousPage';
export const GOTO_NEXT_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetNextPage';
export const GOTO_LAST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFastPage';
export const GOTO_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPage';
export const SET_CUTOFF_UNMET_SORT = 'wanted/cutoffUnmet/setCutoffUnmetSort';
export const SET_CUTOFF_UNMET_FILTER = 'wanted/cutoffUnmet/setCutoffUnmetFilter';
export const SET_CUTOFF_UNMET_TABLE_OPTION = 'wanted/cutoffUnmet/setCutoffUnmetTableOption';
export const CLEAR_CUTOFF_UNMET = 'wanted/cutoffUnmet/clearCutoffUnmet';
export const BATCH_TOGGLE_CUTOFF_UNMET_EPISODES = 'wanted/cutoffUnmet/batchToggleCutoffUnmetEpisodes';
//
// Action Creators
export const fetchMissing = createThunk(FETCH_MISSING);
export const gotoMissingFirstPage = createThunk(GOTO_FIRST_MISSING_PAGE);
export const gotoMissingPreviousPage = createThunk(GOTO_PREVIOUS_MISSING_PAGE);
export const gotoMissingNextPage = createThunk(GOTO_NEXT_MISSING_PAGE);
export const gotoMissingLastPage = createThunk(GOTO_LAST_MISSING_PAGE);
export const gotoMissingPage = createThunk(GOTO_MISSING_PAGE);
export const setMissingSort = createThunk(SET_MISSING_SORT);
export const setMissingFilter = createThunk(SET_MISSING_FILTER);
export const setMissingTableOption = createAction(SET_MISSING_TABLE_OPTION);
export const clearMissing = createAction(CLEAR_MISSING);
export const batchToggleMissingEpisodes = createThunk(BATCH_TOGGLE_MISSING_EPISODES);
export const fetchCutoffUnmet = createThunk(FETCH_CUTOFF_UNMET);
export const gotoCutoffUnmetFirstPage = createThunk(GOTO_FIRST_CUTOFF_UNMET_PAGE);
export const gotoCutoffUnmetPreviousPage = createThunk(GOTO_PREVIOUS_CUTOFF_UNMET_PAGE);
export const gotoCutoffUnmetNextPage = createThunk(GOTO_NEXT_CUTOFF_UNMET_PAGE);
export const gotoCutoffUnmetLastPage = createThunk(GOTO_LAST_CUTOFF_UNMET_PAGE);
export const gotoCutoffUnmetPage = createThunk(GOTO_CUTOFF_UNMET_PAGE);
export const setCutoffUnmetSort = createThunk(SET_CUTOFF_UNMET_SORT);
export const setCutoffUnmetFilter = createThunk(SET_CUTOFF_UNMET_FILTER);
export const setCutoffUnmetTableOption = createAction(SET_CUTOFF_UNMET_TABLE_OPTION);
export const clearCutoffUnmet = createAction(CLEAR_CUTOFF_UNMET);
export const batchToggleCutoffUnmetEpisodes = createThunk(BATCH_TOGGLE_CUTOFF_UNMET_EPISODES);
//
// Action Handlers
export const actionHandlers = handleThunks({
...createServerSideCollectionHandlers(
'wanted.missing',
'/wanted/missing',
fetchMissing,
{
[serverSideCollectionHandlers.FETCH]: FETCH_MISSING,
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_MISSING_PAGE,
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_MISSING_PAGE,
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_MISSING_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_MISSING_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_MISSING_PAGE,
[serverSideCollectionHandlers.SORT]: SET_MISSING_SORT,
[serverSideCollectionHandlers.FILTER]: SET_MISSING_FILTER
}
),
[BATCH_TOGGLE_MISSING_EPISODES]: createBatchToggleEpisodeMonitoredHandler('wanted.missing', fetchMissing),
...createServerSideCollectionHandlers(
'wanted.cutoffUnmet',
'/wanted/cutoff',
fetchCutoffUnmet,
{
[serverSideCollectionHandlers.FETCH]: FETCH_CUTOFF_UNMET,
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_CUTOFF_UNMET_PAGE,
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_CUTOFF_UNMET_PAGE,
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_CUTOFF_UNMET_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_CUTOFF_UNMET_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_CUTOFF_UNMET_PAGE,
[serverSideCollectionHandlers.SORT]: SET_CUTOFF_UNMET_SORT,
[serverSideCollectionHandlers.FILTER]: SET_CUTOFF_UNMET_FILTER
}
),
[BATCH_TOGGLE_CUTOFF_UNMET_EPISODES]: createBatchToggleEpisodeMonitoredHandler('wanted.cutoffUnmet', fetchCutoffUnmet)
});
//
// Reducers
export const reducers = createHandleActions({
[SET_MISSING_TABLE_OPTION]: createSetTableOptionReducer('wanted.missing'),
[SET_CUTOFF_UNMET_TABLE_OPTION]: createSetTableOptionReducer('wanted.cutoffUnmet'),
[CLEAR_MISSING]: createClearReducer(
'wanted.missing',
{
isFetching: false,
isPopulated: false,
error: null,
items: [],
totalPages: 0,
totalRecords: 0
}
),
[CLEAR_CUTOFF_UNMET]: createClearReducer(
'wanted.cutoffUnmet',
{
isFetching: false,
isPopulated: false,
error: null,
items: [],
totalPages: 0,
totalRecords: 0
}
)
}, defaultState, section);

View File

@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
import { SelectProvider, useSelect } from 'App/Select/SelectContext';
import AppState, { Filter } from 'App/State/AppState';
import { Filter } from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -18,21 +18,11 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import usePaging from 'Components/Table/usePaging';
import Episode from 'Episode/Episode';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import { useToggleEpisodesMonitored } from 'Episode/useEpisode';
import { align, icons, kinds } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions';
import { fetchEpisodeFiles } from 'Store/Actions/episodeFileActions';
import {
batchToggleCutoffUnmetEpisodes,
clearCutoffUnmet,
fetchCutoffUnmet,
gotoCutoffUnmetPage,
setCutoffUnmetFilter,
setCutoffUnmetSort,
setCutoffUnmetTableOption,
} from 'Store/Actions/wantedActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { CheckInputChanged } from 'typings/inputs';
import { TableOptionsChangePayload } from 'typings/Table';
@@ -43,34 +33,38 @@ import {
unregisterPagePopulator,
} from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate';
import {
setCutoffUnmetOption,
setCutoffUnmetOptions,
useCutoffUnmetOptions,
} from './cutoffUnmetOptionsStore';
import CutoffUnmetRow from './CutoffUnmetRow';
import useCutoffUnmet, { FILTERS } from './useCutoffUnmet';
function getMonitoredValue(
filters: Filter[],
selectedFilterKey: string
selectedFilterKey: string | number
): boolean {
return !!getFilterValue(filters, selectedFilterKey, 'monitored', false);
}
function CutoffUnmetContent() {
const dispatch = useDispatch();
const requestCurrentPage = useCurrentPage();
const {
isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
records,
totalPages,
totalRecords = 0,
} = useSelector((state: AppState) => state.wanted.cutoffUnmet);
totalRecords,
error,
isFetching,
isLoading,
page,
goToPage,
refetch,
} = useCutoffUnmet();
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useCutoffUnmetOptions();
const isSearchingForAllEpisodes = useSelector(
createCommandExecutingSelector(commandNames.CUTOFF_UNMET_EPISODE_SEARCH)
@@ -91,33 +85,21 @@ function CutoffUnmetContent() {
const [isConfirmSearchAllModalOpen, setIsConfirmSearchAllModalOpen] =
useState(false);
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoCutoffUnmetPage,
});
const { toggleEpisodesMonitored, isToggling } = useToggleEpisodesMonitored([
'/wanted/cutoff',
]);
const isSaving = useMemo(() => {
return items.filter((m) => m.isSaving).length > 1;
}, [items]);
const isShowingMonitored = getMonitoredValue(filters, selectedFilterKey);
const isShowingMonitored = getMonitoredValue(FILTERS, selectedFilterKey);
const isSearchingForEpisodes =
isSearchingForAllEpisodes || isSearchingForSelectedEpisodes;
const episodeIds = useMemo(() => {
return selectUniqueIds<Episode, number>(items, 'id');
}, [items]);
return selectUniqueIds<Episode, number>(records, 'id');
}, [records]);
const episodeFileIds = useMemo(() => {
return selectUniqueIds<Episode, number>(items, 'episodeFileId');
}, [items]);
return selectUniqueIds<Episode, number>(records, 'episodeFileId');
}, [records]);
const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => {
@@ -136,11 +118,11 @@ function CutoffUnmetContent() {
name: commandNames.EPISODE_SEARCH,
episodeIds: getSelectedIds(),
commandFinished: () => {
dispatch(fetchCutoffUnmet());
refetch();
},
})
);
}, [getSelectedIds, dispatch]);
}, [getSelectedIds, dispatch, refetch]);
const handleSearchAllPress = useCallback(() => {
setIsConfirmSearchAllModalOpen(true);
@@ -155,63 +137,43 @@ function CutoffUnmetContent() {
executeCommand({
name: commandNames.CUTOFF_UNMET_EPISODE_SEARCH,
commandFinished: () => {
dispatch(fetchCutoffUnmet());
refetch();
},
})
);
setIsConfirmSearchAllModalOpen(false);
}, [dispatch]);
}, [dispatch, refetch]);
const handleToggleSelectedPress = useCallback(() => {
dispatch(
batchToggleCutoffUnmetEpisodes({
episodeIds: getSelectedIds(),
monitored: !isShowingMonitored,
})
);
}, [isShowingMonitored, getSelectedIds, dispatch]);
toggleEpisodesMonitored({
episodeIds: getSelectedIds(),
monitored: !isShowingMonitored,
});
}, [isShowingMonitored, getSelectedIds, toggleEpisodesMonitored]);
const handleFilterSelect = useCallback(
(filterKey: number | string) => {
dispatch(setCutoffUnmetFilter({ selectedFilterKey: filterKey }));
},
[dispatch]
);
const handleFilterSelect = useCallback((filterKey: number | string) => {
setCutoffUnmetOption('selectedFilterKey', filterKey);
}, []);
const handleSortPress = useCallback(
(sortKey: string) => {
dispatch(setCutoffUnmetSort({ sortKey }));
},
[dispatch]
);
const handleSortPress = useCallback((sortKey: string) => {
setCutoffUnmetOption('sortKey', sortKey);
}, []);
const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => {
dispatch(setCutoffUnmetTableOption(payload));
setCutoffUnmetOptions(payload);
if (payload.pageSize) {
dispatch(gotoCutoffUnmetPage({ page: 1 }));
goToPage(1);
}
},
[dispatch]
[goToPage]
);
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchCutoffUnmet());
} else {
dispatch(gotoCutoffUnmetPage({ page: 1 }));
}
return () => {
dispatch(clearCutoffUnmet());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const repopulate = () => {
dispatch(fetchCutoffUnmet());
refetch();
};
registerPagePopulator(repopulate, [
@@ -223,7 +185,7 @@ function CutoffUnmetContent() {
return () => {
unregisterPagePopulator(repopulate);
};
}, [dispatch]);
}, [refetch]);
useEffect(() => {
if (episodeFileIds.length) {
@@ -260,7 +222,7 @@ function CutoffUnmetContent() {
}
iconName={icons.MONITORED}
isDisabled={!anySelected}
isSpinning={isSaving}
isSpinning={isToggling}
onPress={handleToggleSelectedPress}
/>
</PageToolbarSection>
@@ -280,7 +242,7 @@ function CutoffUnmetContent() {
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
filters={FILTERS}
customFilters={[]}
onFilterSelect={handleFilterSelect}
/>
@@ -288,7 +250,7 @@ function CutoffUnmetContent() {
</PageToolbar>
<PageContentBody>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{isFetching && isLoading ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>
@@ -296,11 +258,11 @@ function CutoffUnmetContent() {
</Alert>
) : null}
{isPopulated && !error && !items.length ? (
{!isLoading && !error && !records.length ? (
<Alert kind={kinds.INFO}>{translate('CutoffUnmetNoItems')}</Alert>
) : null}
{isPopulated && !error && !!items.length ? (
{!isLoading && !error && !!records.length ? (
<div>
<Table
selectAll={true}
@@ -315,7 +277,7 @@ function CutoffUnmetContent() {
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
{records.map((item) => {
return (
<CutoffUnmetRow
key={item.id}
@@ -332,11 +294,7 @@ function CutoffUnmetContent() {
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
onPageSelect={goToPage}
/>
<ConfirmModal
@@ -367,10 +325,10 @@ function CutoffUnmetContent() {
}
export default function CutoffUnmet() {
const { items } = useSelector((state: AppState) => state.wanted.cutoffUnmet);
const { records } = useCutoffUnmet();
return (
<SelectProvider<Episode> items={items}>
<SelectProvider<Episode> items={records}>
<CutoffUnmetContent />
</SelectProvider>
);

View File

@@ -0,0 +1,67 @@
import {
createOptionsStore,
PageableOptions,
} from 'Helpers/Hooks/useOptionsStore';
import translate from 'Utilities/String/translate';
const { useOptions, useOption, setOptions, setOption } =
createOptionsStore<PageableOptions>('cutoffUnmet_options', () => {
return {
pageSize: 20,
selectedFilterKey: 'monitored',
sortKey: 'episodes.airDateUtc',
sortDirection: 'descending',
columns: [
{
name: 'series.sortTitle',
label: () => translate('SeriesTitle'),
isSortable: true,
isVisible: true,
},
{
name: 'episode',
label: () => translate('Episode'),
isVisible: true,
},
{
name: 'episodes.title',
label: () => translate('EpisodeTitle'),
isVisible: true,
},
{
name: 'episodes.airDateUtc',
label: () => translate('AirDate'),
isSortable: true,
isVisible: true,
},
{
name: 'episodes.lastSearchTime',
label: () => translate('LastSearched'),
isSortable: true,
isVisible: false,
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: false,
},
{
name: 'status',
label: () => translate('Status'),
isVisible: true,
},
{
name: 'actions',
label: '',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false,
},
],
};
});
export const useCutoffUnmetOptions = useOptions;
export const setCutoffUnmetOptions = setOptions;
export const useCutoffUnmetOption = useOption;
export const setCutoffUnmetOption = setOption;

View File

@@ -0,0 +1,69 @@
import { keepPreviousData } from '@tanstack/react-query';
import { useEffect } from 'react';
import { Filter } from 'App/State/AppState';
import Episode from 'Episode/Episode';
import { setEpisodeQueryKey } from 'Episode/useEpisode';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
import translate from 'Utilities/String/translate';
import { useCutoffUnmetOptions } from './cutoffUnmetOptionsStore';
export const FILTERS: Filter[] = [
{
key: 'monitored',
label: () => translate('Monitored'),
filters: [
{
key: 'monitored',
value: [true],
type: 'equal',
},
],
},
{
key: 'unmonitored',
label: () => translate('Unmonitored'),
filters: [
{
key: 'monitored',
value: [false],
type: 'equal',
},
],
},
];
const useCutoffUnmet = () => {
const { page, goToPage } = usePage('cutoffUnmet');
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
useCutoffUnmetOptions();
const { isPlaceholderData, queryKey, ...query } = usePagedApiQuery<Episode>({
path: '/wanted/cutoff',
page,
pageSize,
queryParams: {
monitored: selectedFilterKey === 'monitored',
},
sortKey,
sortDirection,
queryOptions: {
placeholderData: keepPreviousData,
},
});
useEffect(() => {
if (!isPlaceholderData) {
setEpisodeQueryKey('wanted.cutoffUnmet', queryKey);
}
}, [isPlaceholderData, queryKey]);
return {
...query,
goToPage,
isPlaceholderData,
page,
};
};
export default useCutoffUnmet;

View File

@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
import { SelectProvider, useSelect } from 'App/Select/SelectContext';
import AppState, { Filter } from 'App/State/AppState';
import { Filter } from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -18,21 +18,11 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import usePaging from 'Components/Table/usePaging';
import Episode from 'Episode/Episode';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import { useToggleEpisodesMonitored } from 'Episode/useEpisode';
import { align, icons, kinds } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import { executeCommand } from 'Store/Actions/commandActions';
import {
batchToggleMissingEpisodes,
clearMissing,
fetchMissing,
gotoMissingPage,
setMissingFilter,
setMissingSort,
setMissingTableOption,
} from 'Store/Actions/wantedActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { CheckInputChanged } from 'typings/inputs';
import { TableOptionsChangePayload } from 'typings/Table';
@@ -43,34 +33,38 @@ import {
unregisterPagePopulator,
} from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate';
import {
setMissingOption,
setMissingOptions,
useMissingOptions,
} from './missingOptionsStore';
import MissingRow from './MissingRow';
import useMissing, { FILTERS } from './useMissing';
function getMonitoredValue(
filters: Filter[],
selectedFilterKey: string
selectedFilterKey: string | number
): boolean {
return !!getFilterValue(filters, selectedFilterKey, 'monitored', false);
}
function MissingContent() {
const dispatch = useDispatch();
const requestCurrentPage = useCurrentPage();
const {
isFetching,
isPopulated,
error,
items,
columns,
selectedFilterKey,
filters,
sortKey,
sortDirection,
page,
pageSize,
records,
totalPages,
totalRecords = 0,
} = useSelector((state: AppState) => state.wanted.missing);
totalRecords,
error,
isFetching,
isLoading,
page,
goToPage,
refetch,
} = useMissing();
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
useMissingOptions();
const isSearchingForAllEpisodes = useSelector(
createCommandExecutingSelector(commandNames.MISSING_EPISODE_SEARCH)
@@ -94,29 +88,17 @@ function MissingContent() {
const [isInteractiveImportModalOpen, setIsInteractiveImportModalOpen] =
useState(false);
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoMissingPage,
});
const { toggleEpisodesMonitored, isToggling } = useToggleEpisodesMonitored([
'/wanted/missing',
]);
const isSaving = useMemo(() => {
return items.filter((m) => m.isSaving).length > 1;
}, [items]);
const isShowingMonitored = getMonitoredValue(filters, selectedFilterKey);
const isShowingMonitored = getMonitoredValue(FILTERS, selectedFilterKey);
const isSearchingForEpisodes =
isSearchingForAllEpisodes || isSearchingForSelectedEpisodes;
const episodeIds = useMemo(() => {
return selectUniqueIds<Episode, number>(items, 'id');
}, [items]);
return selectUniqueIds<Episode, number>(records, 'id');
}, [records]);
const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => {
@@ -135,11 +117,11 @@ function MissingContent() {
name: commandNames.EPISODE_SEARCH,
episodeIds: getSelectedIds(),
commandFinished: () => {
dispatch(fetchMissing());
refetch();
},
})
);
}, [getSelectedIds, dispatch]);
}, [getSelectedIds, dispatch, refetch]);
const handleSearchAllPress = useCallback(() => {
setIsConfirmSearchAllModalOpen(true);
@@ -154,22 +136,20 @@ function MissingContent() {
executeCommand({
name: commandNames.MISSING_EPISODE_SEARCH,
commandFinished: () => {
dispatch(fetchMissing());
refetch();
},
})
);
setIsConfirmSearchAllModalOpen(false);
}, [dispatch]);
}, [dispatch, refetch]);
const handleToggleSelectedPress = useCallback(() => {
dispatch(
batchToggleMissingEpisodes({
episodeIds: getSelectedIds(),
monitored: !isShowingMonitored,
})
);
}, [isShowingMonitored, getSelectedIds, dispatch]);
toggleEpisodesMonitored({
episodeIds: getSelectedIds(),
monitored: !isShowingMonitored,
});
}, [isShowingMonitored, getSelectedIds, toggleEpisodesMonitored]);
const handleInteractiveImportPress = useCallback(() => {
setIsInteractiveImportModalOpen(true);
@@ -179,46 +159,28 @@ function MissingContent() {
setIsInteractiveImportModalOpen(false);
}, []);
const handleFilterSelect = useCallback(
(filterKey: number | string) => {
dispatch(setMissingFilter({ selectedFilterKey: filterKey }));
},
[dispatch]
);
const handleFilterSelect = useCallback((filterKey: number | string) => {
setMissingOption('selectedFilterKey', filterKey);
}, []);
const handleSortPress = useCallback(
(sortKey: string) => {
dispatch(setMissingSort({ sortKey }));
},
[dispatch]
);
const handleSortPress = useCallback((sortKey: string) => {
setMissingOption('sortKey', sortKey);
}, []);
const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => {
dispatch(setMissingTableOption(payload));
setMissingOptions(payload);
if (payload.pageSize) {
dispatch(gotoMissingPage({ page: 1 }));
goToPage(1);
}
},
[dispatch]
[goToPage]
);
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchMissing());
} else {
dispatch(gotoMissingPage({ page: 1 }));
}
return () => {
dispatch(clearMissing());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const repopulate = () => {
dispatch(fetchMissing());
refetch();
};
registerPagePopulator(repopulate, [
@@ -230,7 +192,7 @@ function MissingContent() {
return () => {
unregisterPagePopulator(repopulate);
};
}, [dispatch]);
}, [refetch]);
return (
<QueueDetailsProvider episodeIds={episodeIds}>
@@ -261,7 +223,7 @@ function MissingContent() {
}
iconName={icons.MONITORED}
isDisabled={!anySelected}
isSpinning={isSaving}
isSpinning={isToggling}
onPress={handleToggleSelectedPress}
/>
@@ -289,7 +251,7 @@ function MissingContent() {
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
filters={FILTERS}
customFilters={[]}
onFilterSelect={handleFilterSelect}
/>
@@ -297,17 +259,17 @@ function MissingContent() {
</PageToolbar>
<PageContentBody>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{isFetching && isLoading ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>{translate('MissingLoadError')}</Alert>
) : null}
{isPopulated && !error && !items.length ? (
{!isLoading && !error && !records.length ? (
<Alert kind={kinds.INFO}>{translate('MissingNoItems')}</Alert>
) : null}
{isPopulated && !error && !!items.length ? (
{!isLoading && !error && !!records.length ? (
<div>
<Table
selectAll={true}
@@ -322,7 +284,7 @@ function MissingContent() {
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
{records.map((item) => {
return (
<MissingRow key={item.id} columns={columns} {...item} />
);
@@ -335,11 +297,7 @@ function MissingContent() {
totalPages={totalPages}
totalRecords={totalRecords}
isFetching={isFetching}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
onPageSelect={goToPage}
/>
<ConfirmModal
@@ -377,10 +335,10 @@ function MissingContent() {
}
function Missing() {
const { items } = useSelector((state: AppState) => state.wanted.missing);
const { records } = useMissing();
return (
<SelectProvider<Episode> items={items}>
<SelectProvider<Episode> items={records}>
<MissingContent />
</SelectProvider>
);

View File

@@ -0,0 +1,62 @@
import {
createOptionsStore,
PageableOptions,
} from 'Helpers/Hooks/useOptionsStore';
import translate from 'Utilities/String/translate';
const { useOptions, useOption, setOptions, setOption } =
createOptionsStore<PageableOptions>('missing_options', () => {
return {
pageSize: 20,
selectedFilterKey: 'monitored',
sortKey: 'episodes.airDateUtc',
sortDirection: 'descending',
columns: [
{
name: 'series.sortTitle',
label: () => translate('SeriesTitle'),
isSortable: true,
isVisible: true,
},
{
name: 'episode',
label: () => translate('Episode'),
isVisible: true,
},
{
name: 'episodes.title',
label: () => translate('EpisodeTitle'),
isVisible: true,
},
{
name: 'episodes.airDateUtc',
label: () => translate('AirDate'),
isSortable: true,
isVisible: true,
},
{
name: 'episodes.lastSearchTime',
label: () => translate('LastSearched'),
isSortable: true,
isVisible: false,
},
{
name: 'status',
label: () => translate('Status'),
isVisible: true,
},
{
name: 'actions',
label: '',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false,
},
],
};
});
export const useMissingOptions = useOptions;
export const setMissingOptions = setOptions;
export const useMissingOption = useOption;
export const setMissingOption = setOption;

View File

@@ -0,0 +1,69 @@
import { keepPreviousData } from '@tanstack/react-query';
import { useEffect } from 'react';
import { Filter } from 'App/State/AppState';
import Episode from 'Episode/Episode';
import { setEpisodeQueryKey } from 'Episode/useEpisode';
import usePage from 'Helpers/Hooks/usePage';
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
import translate from 'Utilities/String/translate';
import { useMissingOptions } from './missingOptionsStore';
export const FILTERS: Filter[] = [
{
key: 'monitored',
label: () => translate('Monitored'),
filters: [
{
key: 'monitored',
value: [true],
type: 'equal',
},
],
},
{
key: 'unmonitored',
label: () => translate('Unmonitored'),
filters: [
{
key: 'monitored',
value: [false],
type: 'equal',
},
],
},
];
const useMissing = () => {
const { page, goToPage } = usePage('missing');
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
useMissingOptions();
const { isPlaceholderData, queryKey, ...query } = usePagedApiQuery<Episode>({
path: '/wanted/missing',
page,
pageSize,
queryParams: {
monitored: selectedFilterKey === 'monitored',
},
sortKey,
sortDirection,
queryOptions: {
placeholderData: keepPreviousData,
},
});
useEffect(() => {
if (!isPlaceholderData) {
setEpisodeQueryKey('wanted.missing', queryKey);
}
}, [isPlaceholderData, queryKey]);
return {
...query,
goToPage,
isPlaceholderData,
page,
};
};
export default useMissing;