Use react-query for Naming settings

This commit is contained in:
Mark McDowall 2026-01-01 09:15:54 -08:00
parent 04095df460
commit 7eabd1bb7a
No known key found for this signature in database
11 changed files with 174 additions and 276 deletions

View file

@ -7,6 +7,10 @@ import AppSectionState, {
PagedAppSectionState,
} from 'App/State/AppSectionState';
import Language from 'Language/Language';
import {
NamingSettingsModel,
NamingExamples,
} from 'Settings/MediaManagement/Naming/useNamingSettings';
import AutoTagging, { AutoTaggingSpecification } from 'typings/AutoTagging';
import CustomFormat from 'typings/CustomFormat';
import CustomFormatSpecification from 'typings/CustomFormatSpecification';
@ -21,8 +25,6 @@ import DownloadClientOptions from 'typings/Settings/DownloadClientOptions';
import General from 'typings/Settings/General';
import IndexerOptions from 'typings/Settings/IndexerOptions';
import MediaManagement from 'typings/Settings/MediaManagement';
import NamingConfig from 'typings/Settings/NamingConfig';
import NamingExample from 'typings/Settings/NamingExample';
import MetadataAppState from './MetadataAppState';
type Presets<T> = T & {
@ -66,10 +68,10 @@ export interface MediaManagementAppState
AppSectionSaveState {}
export interface NamingAppState
extends AppSectionItemState<NamingConfig>,
extends AppSectionItemState<NamingSettingsModel>,
AppSectionSaveState {}
export type NamingExamplesAppState = AppSectionItemState<NamingExample>;
export type NamingExamplesAppState = AppSectionItemState<NamingExamples>;
export interface ImportListAppState
extends AppSectionState<ImportList>,

View file

@ -1,7 +1,5 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import React, { useCallback } from 'react';
import { SelectProvider, useSelect } from 'App/Select/SelectContext';
import AppState from 'App/State/AppState';
import CommandNames from 'Commands/CommandNames';
import { useExecuteCommand } from 'Commands/useCommands';
import Alert from 'Components/Alert';
@ -16,7 +14,7 @@ import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import formatSeason from 'Season/formatSeason';
import { useSingleSeries } from 'Series/useSeries';
import { fetchNamingSettings } from 'Store/Actions/settingsActions';
import { useNamingSettings } from 'Settings/MediaManagement/Naming/useNamingSettings';
import { CheckInputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import OrganizePreviewRow from './OrganizePreviewRow';
@ -44,7 +42,6 @@ function OrganizePreviewModalContentInner({
seasonNumber,
onModalClose,
}: OrganizePreviewModalContentProps) {
const dispatch = useDispatch();
const executeCommand = useExecuteCommand();
const {
items,
@ -55,10 +52,10 @@ function OrganizePreviewModalContentInner({
const {
isFetching: isNamingFetching,
isPopulated: isNamingPopulated,
isFetched: isNamingFetched,
error: namingError,
item: naming,
} = useSelector((state: AppState) => state.settings.naming);
data: naming,
} = useNamingSettings();
const series = useSingleSeries(seriesId)!;
@ -66,7 +63,7 @@ function OrganizePreviewModalContentInner({
useSelect<OrganizePreviewModel>();
const isFetching = isPreviewFetching || isNamingFetching;
const isPopulated = isPreviewFetched && isNamingPopulated;
const isPopulated = isPreviewFetched && isNamingFetched;
const error = previewError || namingError;
const { renameEpisodes } = naming;
const episodeFormat = naming[`${series.seriesType}EpisodeFormat`];
@ -96,10 +93,6 @@ function OrganizePreviewModalContentInner({
onModalClose();
}, [seriesId, getSelectedIds, executeCommand, onModalClose]);
useEffect(() => {
dispatch(fetchNamingSettings());
}, [dispatch]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>

View file

@ -1,6 +1,5 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
@ -19,13 +18,12 @@ import { clearPendingChanges } from 'Store/Actions/baseActions';
import {
fetchMediaManagementSettings,
saveMediaManagementSettings,
saveNamingSettings,
setMediaManagementSettingsValue,
} from 'Store/Actions/settingsActions';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import { useIsWindows } from 'System/Status/useSystemStatus';
import { InputChanged } from 'typings/inputs';
import isEmpty from 'Utilities/Object/isEmpty';
import { SettingsStateChange } from 'typings/Settings/SettingsState';
import translate from 'Utilities/String/translate';
import Naming from './Naming/Naming';
import AddRootFolder from './RootFolder/AddRootFolder';
@ -140,10 +138,9 @@ const seasonPackUpgradeOptions: EnhancedSelectInputValue<string>[] = [
function MediaManagement() {
const dispatch = useDispatch();
const showAdvancedSettings = useShowAdvancedSettings();
const hasNamingPendingChanges = !isEmpty(
useSelector((state: AppState) => state.settings.naming.pendingChanges)
);
const isWindows = useIsWindows();
const {
isFetching,
isPopulated,
@ -156,9 +153,24 @@ function MediaManagement() {
validationWarnings,
} = useSelector(createSettingsSectionSelector(SECTION));
const [naming, setNaming] = useState<SettingsStateChange>({
isSaving: false,
hasPendingChanges: false,
});
const saveSettings = useRef<{
naming: () => void;
}>({
naming: () => {},
});
const handleSetNamingSave = useCallback((saveCallback: () => void) => {
saveSettings.current.naming = saveCallback;
}, []);
const handleSavePress = useCallback(() => {
dispatch(saveMediaManagementSettings());
dispatch(saveNamingSettings());
saveSettings.current.naming();
}, [dispatch]);
const handleInputChange = useCallback(
@ -180,13 +192,16 @@ function MediaManagement() {
return (
<PageContent title={translate('MediaManagementSettings')}>
<SettingsToolbar
isSaving={isSaving}
hasPendingChanges={hasNamingPendingChanges || hasPendingChanges}
isSaving={isSaving || naming.isSaving}
hasPendingChanges={naming.hasPendingChanges || hasPendingChanges}
onSavePress={handleSavePress}
/>
<PageContentBody>
<Naming />
<Naming
setChildSave={handleSetNamingSave}
onChildStateChange={setNaming}
/>
{isFetching ? (
<FieldSet legend={translate('NamingSettings')}>

View file

@ -1,7 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import React, { useCallback, useEffect, useState } from 'react';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
@ -11,41 +8,23 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import useDebounce from 'Helpers/Hooks/useDebounce';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import { useShowAdvancedSettings } from 'Settings/advancedSettingsStore';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import {
fetchNamingExamples,
fetchNamingSettings,
setNamingSettingsValue,
} from 'Store/Actions/settingsActions';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import { InputChanged } from 'typings/inputs';
import NamingConfig from 'typings/Settings/NamingConfig';
import translate from 'Utilities/String/translate';
import NamingModal from './NamingModal';
import {
NamingSettingsModel,
useManageNamingSettings,
useNamingExamples,
} from './useNamingSettings';
import styles from './Naming.css';
const SECTION = 'naming';
function createNamingSelector() {
return createSelector(
(state: AppState) => state.settings.namingExamples,
createSettingsSectionSelector(SECTION),
(namingExamples, sectionSettings) => {
return {
examples: namingExamples.item,
examplesPopulated: namingExamples.isPopulated,
...sectionSettings,
};
}
);
}
interface NamingModalOptions {
name: keyof Pick<
NamingConfig,
NamingSettingsModel,
| 'standardEpisodeFormat'
| 'dailyEpisodeFormat'
| 'animeEpisodeFormat'
@ -60,51 +39,46 @@ interface NamingModalOptions {
additional?: boolean;
}
function Naming() {
interface NamingProps {
setChildSave: (saveCallback: () => void) => void;
onChildStateChange: (state: {
isSaving: boolean;
hasPendingChanges: boolean;
}) => void;
}
function Naming({ setChildSave, onChildStateChange }: NamingProps) {
const advancedSettings = useShowAdvancedSettings();
const {
settings,
updateSetting,
isFetching,
error,
settings,
hasSettings,
examples,
examplesPopulated,
} = useSelector(createNamingSelector());
hasPendingChanges,
isSaving,
saveSettings,
} = useManageNamingSettings();
const dispatch = useDispatch();
const debouncedSettings = useDebounce(settings, 300);
const { examples } = useNamingExamples(debouncedSettings);
const examplesPopulated = !!examples;
const [isNamingModalOpen, setNamingModalOpen, setNamingModalClosed] =
useModalOpenState(false);
const [namingModalOptions, setNamingModalOptions] =
useState<NamingModalOptions | null>(null);
const namingExampleTimeout = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
dispatch(fetchNamingSettings());
dispatch(fetchNamingExamples());
return () => {
dispatch(clearPendingChanges({ section: 'settings.naming' }));
};
}, [dispatch]);
const handleInputChange = useCallback(
(change: InputChanged) => {
// @ts-expect-error 'setNamingSettingsValue' isn't typed yet
dispatch(setNamingSettingsValue(change));
const key = change.name as keyof NamingSettingsModel;
if (namingExampleTimeout.current) {
clearTimeout(namingExampleTimeout.current);
}
namingExampleTimeout.current = setTimeout(() => {
dispatch(fetchNamingExamples());
}, 1000);
updateSetting(key, change.value as NamingSettingsModel[typeof key]);
},
[dispatch]
[updateSetting]
);
const onStandardNamingModalOpenClick = useCallback(() => {
const handleStandardNamingModalOpenClick = useCallback(() => {
setNamingModalOpen();
setNamingModalOptions({
@ -115,7 +89,7 @@ function Naming() {
});
}, [setNamingModalOpen, setNamingModalOptions]);
const onDailyNamingModalOpenClick = useCallback(() => {
const handleDailyNamingModalOpenClick = useCallback(() => {
setNamingModalOpen();
setNamingModalOptions({
@ -127,7 +101,7 @@ function Naming() {
});
}, [setNamingModalOpen, setNamingModalOptions]);
const onAnimeNamingModalOpenClick = useCallback(() => {
const handleAnimeNamingModalOpenClick = useCallback(() => {
setNamingModalOpen();
setNamingModalOptions({
@ -139,7 +113,7 @@ function Naming() {
});
}, [setNamingModalOpen, setNamingModalOptions]);
const onSeriesFolderNamingModalOpenClick = useCallback(() => {
const handleSeriesFolderNamingModalOpenClick = useCallback(() => {
setNamingModalOpen();
setNamingModalOptions({
@ -147,7 +121,7 @@ function Naming() {
});
}, [setNamingModalOpen, setNamingModalOptions]);
const onSeasonFolderNamingModalOpenClick = useCallback(() => {
const handleSeasonFolderNamingModalOpenClick = useCallback(() => {
setNamingModalOpen();
setNamingModalOptions({
@ -156,7 +130,7 @@ function Naming() {
});
}, [setNamingModalOpen, setNamingModalOptions]);
const onSpecialsFolderNamingModalOpenClick = useCallback(() => {
const handleSpecialsFolderNamingModalOpenClick = useCallback(() => {
setNamingModalOpen();
setNamingModalOptions({
@ -282,6 +256,17 @@ function Naming() {
}
}
useEffect(() => {
onChildStateChange({
hasPendingChanges,
isSaving,
});
}, [hasPendingChanges, isSaving, onChildStateChange]);
useEffect(() => {
setChildSave(saveSettings);
}, [setChildSave, saveSettings]);
return (
<FieldSet legend={translate('EpisodeNaming')}>
{isFetching ? <LoadingIndicator /> : null}
@ -358,7 +343,9 @@ function Naming() {
type={inputTypes.TEXT}
name="standardEpisodeFormat"
buttons={
<FormInputButton onPress={onStandardNamingModalOpenClick}>
<FormInputButton
onPress={handleStandardNamingModalOpenClick}
>
?
</FormInputButton>
}
@ -380,7 +367,7 @@ function Naming() {
type={inputTypes.TEXT}
name="dailyEpisodeFormat"
buttons={
<FormInputButton onPress={onDailyNamingModalOpenClick}>
<FormInputButton onPress={handleDailyNamingModalOpenClick}>
?
</FormInputButton>
}
@ -402,7 +389,7 @@ function Naming() {
type={inputTypes.TEXT}
name="animeEpisodeFormat"
buttons={
<FormInputButton onPress={onAnimeNamingModalOpenClick}>
<FormInputButton onPress={handleAnimeNamingModalOpenClick}>
?
</FormInputButton>
}
@ -430,7 +417,9 @@ function Naming() {
type={inputTypes.TEXT}
name="seriesFolderFormat"
buttons={
<FormInputButton onPress={onSeriesFolderNamingModalOpenClick}>
<FormInputButton
onPress={handleSeriesFolderNamingModalOpenClick}
>
?
</FormInputButton>
}
@ -455,7 +444,9 @@ function Naming() {
type={inputTypes.TEXT}
name="seasonFolderFormat"
buttons={
<FormInputButton onPress={onSeasonFolderNamingModalOpenClick}>
<FormInputButton
onPress={handleSeasonFolderNamingModalOpenClick}
>
?
</FormInputButton>
}
@ -481,7 +472,9 @@ function Naming() {
type={inputTypes.TEXT}
name="specialsFolderFormat"
buttons={
<FormInputButton onPress={onSpecialsFolderNamingModalOpenClick}>
<FormInputButton
onPress={handleSpecialsFolderNamingModalOpenClick}
>
?
</FormInputButton>
}

View file

@ -10,11 +10,11 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { sizes } from 'Helpers/Props';
import NamingConfig from 'typings/Settings/NamingConfig';
import translate from 'Utilities/String/translate';
import NamingOption from './NamingOption';
import TokenCase from './TokenCase';
import TokenSeparator from './TokenSeparator';
import { NamingSettingsModel } from './useNamingSettings';
import styles from './NamingModal.css';
type SeparatorInputOption = Omit<SelectInputOption, 'key'> & {
@ -274,7 +274,7 @@ const originalTokens = [
interface NamingModalProps {
isOpen: boolean;
name: keyof Pick<
NamingConfig,
NamingSettingsModel,
| 'standardEpisodeFormat'
| 'dailyEpisodeFormat'
| 'animeEpisodeFormat'

View file

@ -0,0 +1,71 @@
import { keepPreviousData } from '@tanstack/react-query';
import { useMemo } from 'react';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import { useManageSettings, useSettings } from 'Settings/useSettings';
import { PendingSection } from 'typings/pending';
import { QueryParams } from 'Utilities/Fetch/getQueryString';
const PATH = '/settings/naming';
const EXAMPLES_PATH = '/settings/naming/examples';
export interface NamingSettingsModel {
renameEpisodes: boolean;
replaceIllegalCharacters: boolean;
colonReplacementFormat: number;
customColonReplacementFormat: string;
multiEpisodeStyle: number;
standardEpisodeFormat: string;
dailyEpisodeFormat: string;
animeEpisodeFormat: string;
seriesFolderFormat: string;
seasonFolderFormat: string;
specialsFolderFormat: string;
}
export interface NamingExamples {
singleEpisodeExample: string;
multiEpisodeExample: string;
dailyEpisodeExample: string;
animeEpisodeExample: string;
animeMultiEpisodeExample: string;
seriesFolderExample: string;
seasonFolderExample: string;
specialsFolderExample: string;
}
export const useNamingSettings = () => {
return useSettings<NamingSettingsModel>(PATH);
};
export const useManageNamingSettings = () => {
return useManageSettings<NamingSettingsModel>(PATH);
};
export const useNamingExamples = (
settings: PendingSection<NamingSettingsModel>
) => {
const queryParams = useMemo<QueryParams>(() => {
return Object.entries(settings).reduce((acc, [key, value]) => {
if (typeof value === 'object' && 'value' in value) {
acc[key] = value.value;
}
return acc;
}, {} as QueryParams);
}, [settings]);
const { data, error, isFetching } = useApiQuery<NamingExamples>({
path: EXAMPLES_PATH,
method: 'GET',
queryParams,
queryOptions: {
placeholderData: keepPreviousData,
},
});
return {
examples: data,
isExamplesFetching: isFetching,
examplesError: error,
};
};

View file

@ -1,64 +0,0 @@
import { createAction } from 'redux-actions';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
//
// Variables
const section = 'settings.naming';
//
// Actions Types
export const FETCH_NAMING_SETTINGS = 'settings/naming/fetchNamingSettings';
export const SAVE_NAMING_SETTINGS = 'settings/naming/saveNamingSettings';
export const SET_NAMING_SETTINGS_VALUE = 'settings/naming/setNamingSettingsValue';
//
// Action Creators
export const fetchNamingSettings = createThunk(FETCH_NAMING_SETTINGS);
export const saveNamingSettings = createThunk(SAVE_NAMING_SETTINGS);
export const setNamingSettingsValue = createAction(SET_NAMING_SETTINGS_VALUE, (payload) => {
return {
section,
...payload
};
});
//
// Details
export default {
//
// State
defaultState: {
isFetching: false,
isPopulated: false,
error: null,
pendingChanges: {},
isSaving: false,
saveError: null,
item: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_NAMING_SETTINGS]: createFetchHandler(section, '/config/naming'),
[SAVE_NAMING_SETTINGS]: createSaveHandler(section, '/config/naming')
},
//
// Reducers
reducers: {
[SET_NAMING_SETTINGS_VALUE]: createSetSettingValueReducer(section)
}
};

View file

@ -1,79 +0,0 @@
import { batchActions } from 'redux-batched-actions';
import { set, update } from 'Store/Actions/baseActions';
import { createThunk } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
//
// Variables
const section = 'settings.namingExamples';
//
// Actions Types
export const FETCH_NAMING_EXAMPLES = 'settings/namingExamples/fetchNamingExamples';
//
// Action Creators
export const fetchNamingExamples = createThunk(FETCH_NAMING_EXAMPLES);
//
// Details
export default {
//
// State
defaultState: {
isFetching: false,
isPopulated: false,
error: null,
item: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_NAMING_EXAMPLES]: function(getState, payload, dispatch) {
dispatch(set({ section, isFetching: true }));
const naming = getState().settings.naming;
const promise = createAjaxRequest({
url: '/config/naming/examples',
data: Object.assign({}, naming.item, naming.pendingChanges)
}).request;
promise.done((data) => {
dispatch(batchActions([
update({ section, data }),
set({
section,
isFetching: false,
isPopulated: true,
error: null
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isFetching: false,
isPopulated: false,
error: xhr
}));
});
}
},
//
// Reducers
reducers: {}
};

View file

@ -17,8 +17,6 @@ import indexers from './Settings/indexers';
import languages from './Settings/languages';
import mediaManagement from './Settings/mediaManagement';
import metadata from './Settings/metadata';
import naming from './Settings/naming';
import namingExamples from './Settings/namingExamples';
export * from './Settings/autoTaggingSpecifications';
export * from './Settings/autoTaggings';
@ -37,8 +35,6 @@ export * from './Settings/indexers';
export * from './Settings/languages';
export * from './Settings/mediaManagement';
export * from './Settings/metadata';
export * from './Settings/naming';
export * from './Settings/namingExamples';
//
// Variables
@ -66,9 +62,7 @@ export const defaultState = {
indexers: indexers.defaultState,
languages: languages.defaultState,
mediaManagement: mediaManagement.defaultState,
metadata: metadata.defaultState,
naming: naming.defaultState,
namingExamples: namingExamples.defaultState
metadata: metadata.defaultState
};
export const persistState = [
@ -95,9 +89,7 @@ export const actionHandlers = handleThunks({
...indexers.actionHandlers,
...languages.actionHandlers,
...mediaManagement.actionHandlers,
...metadata.actionHandlers,
...naming.actionHandlers,
...namingExamples.actionHandlers
...metadata.actionHandlers
});
//
@ -120,8 +112,6 @@ export const reducers = createHandleActions({
...indexers.reducers,
...languages.reducers,
...mediaManagement.reducers,
...metadata.reducers,
...naming.reducers,
...namingExamples.reducers
...metadata.reducers
}, defaultState, section);

View file

@ -1,13 +0,0 @@
export default interface NamingConfig {
renameEpisodes: boolean;
replaceIllegalCharacters: boolean;
colonReplacementFormat: number;
customColonReplacementFormat: string;
multiEpisodeStyle: number;
standardEpisodeFormat: string;
dailyEpisodeFormat: string;
animeEpisodeFormat: string;
seriesFolderFormat: string;
seasonFolderFormat: string;
specialsFolderFormat: string;
}

View file

@ -1,10 +0,0 @@
export default interface NamingExample {
singleEpisodeExample: string;
multiEpisodeExample: string;
dailyEpisodeExample: string;
animeEpisodeExample: string;
animeMultiEpisodeExample: string;
seriesFolderExample: string;
seasonFolderExample: string;
specialsFolderExample: string;
}