Use react-query for Connections

This commit is contained in:
Mark McDowall 2025-12-30 15:00:46 -08:00
parent 0d80c093ff
commit 6d49b41dd2
19 changed files with 606 additions and 378 deletions

View file

@ -73,8 +73,6 @@ export const useEnsureImportSeriesItems = (
export const updateImportSeriesItem = (
itemData: Partial<ImportSeriesItem> & Pick<ImportSeriesItem, 'id'>
) => {
console.info('\x1b[36m[MarkTest] updating item\x1b[0m', itemData);
importSeriesStore.setState((state) => {
const existingItem = state.items[itemData.id];

View file

@ -17,7 +17,6 @@ import ImportListExclusion from 'typings/ImportListExclusion';
import ImportListOptionsSettings from 'typings/ImportListOptionsSettings';
import Indexer from 'typings/Indexer';
import IndexerFlag from 'typings/IndexerFlag';
import Notification from 'typings/Notification';
import DownloadClientOptions from 'typings/Settings/DownloadClientOptions';
import General from 'typings/Settings/General';
import IndexerOptions from 'typings/Settings/IndexerOptions';
@ -92,12 +91,6 @@ export interface IndexerAppState
isTestingAll: boolean;
}
export interface NotificationAppState
extends AppSectionState<Notification>,
AppSectionDeleteState,
AppSectionSaveState,
AppSectionSchemaState<Presets<Notification>> {}
export interface CustomFormatAppState
extends AppSectionState<CustomFormat>,
AppSectionDeleteState,
@ -144,7 +137,6 @@ interface SettingsAppState {
metadata: MetadataAppState;
naming: NamingAppState;
namingExamples: NamingExamplesAppState;
notifications: NotificationAppState;
}
export default SettingsAppState;

View file

@ -0,0 +1,91 @@
import { useCallback, useMemo, useState } from 'react';
import { create, useStore } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
interface PendingFieldsStore {
pendingFields: Map<string, unknown>;
}
export const usePendingFieldsStore = () => {
// eslint-disable-next-line react/hook-use-state
const [store] = useState(() => {
return create<PendingFieldsStore>()((_set) => {
return {
pendingFields: new Map(),
};
});
});
const setPendingField = useCallback(
(name: string, value: unknown) => {
store.setState((state) => {
const newPendingFields = new Map(state.pendingFields);
newPendingFields.set(name, value);
return {
...state,
pendingFields: newPendingFields,
};
});
},
[store]
);
const setPendingFields = useCallback(
(fieldProperties: Record<string, unknown>) => {
store.setState((state) => {
const newPendingFields = new Map(state.pendingFields);
Object.entries(fieldProperties).forEach(([key, value]) => {
newPendingFields.set(key, value);
});
return {
...state,
pendingFields: newPendingFields,
};
});
},
[store]
);
const unsetPendingField = useCallback(
(name: string) => {
store.setState((state) => {
const newPendingFields = new Map(state.pendingFields);
newPendingFields.delete(name);
return {
...state,
pendingFields: newPendingFields,
};
});
},
[store]
);
const clearPendingFields = useCallback(() => {
store.setState((state) => ({
...state,
pendingFields: new Map(),
}));
}, [store]);
const pendingFields = useStore(
store,
useShallow((state) => {
return state.pendingFields;
})
);
const hasPendingFields = useMemo(() => {
return pendingFields.size > 0;
}, [pendingFields]);
return {
store,
pendingFields,
setPendingField,
setPendingFields,
unsetPendingField,
clearPendingFields,
hasPendingFields,
};
};

View file

@ -1,13 +1,12 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import Menu from 'Components/Menu/Menu';
import MenuContent from 'Components/Menu/MenuContent';
import { sizes } from 'Helpers/Props';
import { selectNotificationSchema } from 'Store/Actions/settingsActions';
import Notification from 'typings/Notification';
import { SelectedSchema } from 'Settings/useProviderSchema';
import translate from 'Utilities/String/translate';
import { NotificationModel } from '../useConnections';
import AddNotificationPresetMenuItem from './AddNotificationPresetMenuItem';
import styles from './AddNotificationItem.css';
@ -15,8 +14,8 @@ interface AddNotificationItemProps {
implementation: string;
implementationName: string;
infoLink: string;
presets?: Notification[];
onNotificationSelect: () => void;
presets?: NotificationModel[];
onNotificationSelect: (selectedScehema: SelectedSchema) => void;
}
function AddNotificationItem({
@ -26,19 +25,11 @@ function AddNotificationItem({
presets,
onNotificationSelect,
}: AddNotificationItemProps) {
const dispatch = useDispatch();
const hasPresets = !!presets && !!presets.length;
const handleNotificationSelect = useCallback(() => {
dispatch(
selectNotificationSchema({
implementation,
implementationName,
})
);
onNotificationSelect();
}, [implementation, implementationName, dispatch, onNotificationSelect]);
onNotificationSelect({ implementation, implementationName });
}, [implementation, implementationName, onNotificationSelect]);
return (
<div className={styles.notification}>

View file

@ -1,6 +1,4 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import React from 'react';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Button from 'Components/Link/Button';
@ -10,13 +8,14 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import { fetchNotificationSchema } from 'Store/Actions/settingsActions';
import { SelectedSchema } from 'Settings/useProviderSchema';
import translate from 'Utilities/String/translate';
import { useConnectionSchema } from '../useConnections';
import AddNotificationItem from './AddNotificationItem';
import styles from './AddNotificationModalContent.css';
export interface AddNotificationModalContentProps {
onNotificationSelect: () => void;
onNotificationSelect: (selectedScehema: SelectedSchema) => void;
onModalClose: () => void;
}
@ -24,27 +23,21 @@ function AddNotificationModalContent({
onNotificationSelect,
onModalClose,
}: AddNotificationModalContentProps) {
const dispatch = useDispatch();
const { isSchemaFetching, isSchemaPopulated, schemaError, schema } =
useSelector((state: AppState) => state.settings.notifications);
useEffect(() => {
dispatch(fetchNotificationSchema());
}, [dispatch]);
const { isSchemaFetching, isSchemaFetched, schemaError, schema } =
useConnectionSchema();
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('AddNotification')}</ModalHeader>
<ModalBody>
{isSchemaFetching ? <LoadingIndicator /> : null}
{isSchemaFetching && !isSchemaFetched ? <LoadingIndicator /> : null}
{!isSchemaFetching && !!schemaError ? (
<Alert kind={kinds.DANGER}>{translate('AddNotificationError')}</Alert>
) : null}
{isSchemaPopulated && !schemaError ? (
{isSchemaFetched && !schemaError ? (
<div>
<Alert kind={kinds.INFO}>
<div>{translate('SupportedNotifications')}</div>

View file

@ -1,13 +1,12 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import MenuItem from 'Components/Menu/MenuItem';
import { selectNotificationSchema } from 'Store/Actions/settingsActions';
import { SelectedSchema } from 'Settings/useProviderSchema';
interface AddNotificationPresetMenuItemProps {
name: string;
implementation: string;
implementationName: string;
onPress: () => void;
onPress: (selectedScehema: SelectedSchema) => void;
}
function AddNotificationPresetMenuItem({
@ -17,19 +16,9 @@ function AddNotificationPresetMenuItem({
onPress,
...otherProps
}: AddNotificationPresetMenuItemProps) {
const dispatch = useDispatch();
const handlePress = useCallback(() => {
dispatch(
selectNotificationSchema({
implementation,
implementationName,
presetName: name,
})
);
onPress();
}, [name, implementation, implementationName, dispatch, onPress]);
onPress({ implementation, implementationName, presetName: name });
}, [name, implementation, implementationName, onPress]);
return (
<MenuItem {...otherProps} onPress={handlePress}>

View file

@ -3,10 +3,6 @@ import { useDispatch } from 'react-redux';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import {
cancelSaveNotification,
cancelTestNotification,
} from 'Store/Actions/settingsActions';
import EditNotificationModalContent, {
EditNotificationModalContentProps,
} from './EditNotificationModalContent';
@ -26,8 +22,6 @@ function EditNotificationModal({
const handleModalClose = useCallback(() => {
dispatch(clearPendingChanges({ section }));
dispatch(cancelTestNotification({ section }));
dispatch(cancelSaveNotification({ section }));
onModalClose();
}, [dispatch, onModalClose]);

View file

@ -1,6 +1,4 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { NotificationAppState } from 'App/State/SettingsAppState';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
@ -9,7 +7,6 @@ import FormLabel from 'Components/Form/FormLabel';
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
@ -18,48 +15,45 @@ import usePrevious from 'Helpers/Hooks/usePrevious';
import { inputTypes, kinds } from 'Helpers/Props';
import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton';
import { useShowAdvancedSettings } from 'Settings/advancedSettingsStore';
import {
saveNotification,
setNotificationFieldValues,
setNotificationValue,
testNotification,
} from 'Store/Actions/settingsActions';
import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector';
import { useManageConnection } from 'Settings/Notifications/useConnections';
import { SelectedSchema } from 'Settings/useProviderSchema';
import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
import Notification from 'typings/Notification';
import translate from 'Utilities/String/translate';
import NotificationEventItems from './NotificationEventItems';
import styles from './EditNotificationModalContent.css';
export interface EditNotificationModalContentProps {
id?: number;
selectedSchema?: SelectedSchema;
onModalClose: () => void;
onDeleteNotificationPress?: () => void;
}
function EditNotificationModalContent({
id,
selectedSchema,
onModalClose,
onDeleteNotificationPress,
}: EditNotificationModalContentProps) {
const dispatch = useDispatch();
const showAdvancedSettings = useShowAdvancedSettings();
const result = useManageConnection(id, selectedSchema);
const {
isFetching,
error,
isSaving,
isTesting = false,
saveError,
item,
updateValue,
saveProvider,
isSaving,
saveError,
testProvider,
isTesting,
validationErrors,
validationWarnings,
} = useSelector(
createProviderSettingsSelectorHook<Notification, NotificationAppState>(
'notifications',
id
)
);
} = result;
// updateFieldValue is guaranteed to exist for NotificationModel since it extends Provider
const { updateFieldValue } = result as typeof result & {
updateFieldValue: (fieldProperties: Record<string, unknown>) => void;
};
const wasSaving = usePrevious(isSaving);
@ -67,10 +61,10 @@ function EditNotificationModalContent({
const handleInputChange = useCallback(
(change: InputChanged) => {
// @ts-expect-error - actions are not typed
dispatch(setNotificationValue(change));
// @ts-expect-error - change is not yet typed
updateValue(change.name, change.value);
},
[dispatch]
[updateValue]
);
const handleFieldChange = useCallback(
@ -79,23 +73,18 @@ function EditNotificationModalContent({
value,
additionalProperties,
}: EnhancedSelectInputChanged<unknown>) => {
dispatch(
// @ts-expect-error - actions are not typed
setNotificationFieldValues({
properties: { [name]: value, ...additionalProperties },
})
);
updateFieldValue({ [name]: value, ...additionalProperties });
},
[dispatch]
[updateFieldValue]
);
const handleTestPress = useCallback(() => {
dispatch(testNotification({ id }));
}, [id, dispatch]);
testProvider();
}, [testProvider]);
const handleSavePress = useCallback(() => {
dispatch(saveNotification({ id }));
}, [id, dispatch]);
saveProvider();
}, [saveProvider]);
useEffect(() => {
if (wasSaving && !isSaving && !saveError) {
@ -112,65 +101,57 @@ function EditNotificationModalContent({
</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
<Form
validationErrors={validationErrors}
validationWarnings={validationWarnings}
>
{message ? (
<Alert className={styles.message} kind={message.value.type}>
{message.value.message}
</Alert>
) : null}
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>{translate('AddNotificationError')}</Alert>
) : null}
<FormGroup>
<FormLabel>{translate('Name')}</FormLabel>
{!isFetching && !error ? (
<Form
validationErrors={validationErrors}
validationWarnings={validationWarnings}
>
{message ? (
<Alert className={styles.message} kind={message.value.type}>
{message.value.message}
</Alert>
) : null}
<FormGroup>
<FormLabel>{translate('Name')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={handleInputChange}
/>
</FormGroup>
<NotificationEventItems
item={item}
onInputChange={handleInputChange}
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<NotificationEventItems
item={item}
onInputChange={handleInputChange}
/>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('NotificationsTagsSeriesHelpText')}
{...tags}
onChange={handleInputChange}
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('NotificationsTagsSeriesHelpText')}
{...tags}
onChange={handleInputChange}
/>
</FormGroup>
{fields.map((field) => {
return (
<ProviderFieldFormGroup
key={field.name}
{...field}
advancedSettings={showAdvancedSettings}
provider="notification"
providerData={item}
onChange={handleFieldChange}
/>
</FormGroup>
{fields.map((field) => {
return (
<ProviderFieldFormGroup
key={field.name}
{...field}
advancedSettings={showAdvancedSettings}
provider="notification"
providerData={item}
onChange={handleFieldChange}
/>
);
})}
</Form>
) : null}
);
})}
</Form>
</ModalBody>
<ModalFooter>

View file

@ -1,14 +1,12 @@
import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import Card from 'Components/Card';
import Label from 'Components/Label';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import { kinds } from 'Helpers/Props';
import { deleteNotification } from 'Store/Actions/settingsActions';
import { useTagList } from 'Tags/useTags';
import NotificationModel from 'typings/Notification';
import translate from 'Utilities/String/translate';
import { NotificationModel, useDeleteConnection } from '../useConnections';
import EditNotificationModal from './EditNotificationModal';
import styles from './Notification.css';
@ -43,8 +41,8 @@ function Notification({
supportsOnManualInteractionRequired,
tags,
}: NotificationModel) {
const dispatch = useDispatch();
const tagList = useTagList();
const { deleteConnection } = useDeleteConnection(id);
const [isEditNotificationModalOpen, setIsEditNotificationModalOpen] =
useState(false);
@ -69,8 +67,8 @@ function Notification({
}, []);
const handleConfirmDeleteNotification = useCallback(() => {
dispatch(deleteNotification({ id }));
}, [id, dispatch]);
deleteConnection();
}, [deleteConnection]);
return (
<Card

View file

@ -5,13 +5,13 @@ import FormInputHelpText from 'Components/Form/FormInputHelpText';
import FormLabel from 'Components/Form/FormLabel';
import { inputTypes } from 'Helpers/Props';
import { CheckInputChanged } from 'typings/inputs';
import Notification from 'typings/Notification';
import { PendingSection } from 'typings/pending';
import translate from 'Utilities/String/translate';
import { NotificationModel } from '../useConnections';
import styles from './NotificationEventItems.css';
interface NotificationEventItemsProps {
item: PendingSection<Notification>;
item: PendingSection<NotificationModel>;
onInputChange: (change: CheckInputChanged) => void;
}

View file

@ -1,30 +1,24 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { NotificationAppState } from 'App/State/SettingsAppState';
import React, { useCallback, useState } from 'react';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { icons } from 'Helpers/Props';
import { fetchNotifications } from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import NotificationModel from 'typings/Notification';
import sortByProp from 'Utilities/Array/sortByProp';
import { SelectedSchema } from 'Settings/useProviderSchema';
import translate from 'Utilities/String/translate';
import { useConnections, useSortedConnections } from '../useConnections';
import AddNotificationModal from './AddNotificationModal';
import EditNotificationModal from './EditNotificationModal';
import Notification from './Notification';
import styles from './Notifications.css';
function Notifications() {
const dispatch = useDispatch();
const { error, isFetching, isFetched } = useConnections();
const items = useSortedConnections();
const { error, isFetching, isPopulated, items } = useSelector(
createSortedSectionSelector<NotificationModel, NotificationAppState>(
'settings.notifications',
sortByProp('name')
)
);
const [selectedSchema, setSelectedSchema] = useState<
SelectedSchema | undefined
>(undefined);
const [isAddNotificationModalOpen, setIsAddNotificationModalOpen] =
useState(false);
@ -36,7 +30,8 @@ function Notifications() {
setIsAddNotificationModalOpen(true);
}, []);
const handleNotificationSelect = useCallback(() => {
const handleNotificationSelect = useCallback((selected: SelectedSchema) => {
setSelectedSchema(selected);
setIsAddNotificationModalOpen(false);
setIsEditNotificationModalOpen(true);
}, []);
@ -49,17 +44,13 @@ function Notifications() {
setIsEditNotificationModalOpen(false);
}, []);
useEffect(() => {
dispatch(fetchNotifications());
}, [dispatch]);
return (
<FieldSet legend={translate('Connections')}>
<PageSectionContent
errorMessage={translate('NotificationsLoadError')}
error={error}
isFetching={isFetching}
isPopulated={isPopulated}
isPopulated={isFetched}
>
<div className={styles.notifications}>
{items.map((item) => (
@ -84,6 +75,7 @@ function Notifications() {
<EditNotificationModal
isOpen={isEditNotificationModalOpen}
selectedSchema={selectedSchema}
onModalClose={handleEditNotificationModalClose}
/>
</PageSectionContent>

View file

@ -0,0 +1,133 @@
import { useMemo } from 'react';
import {
SelectedSchema,
useProviderSchema,
useSelectedSchema,
} from 'Settings/useProviderSchema';
import {
useDeleteProvider,
useManageProviderSettings,
useProviderSettings,
} from 'Settings/useProviderSettings';
import Provider from 'typings/Provider';
import { sortByProp } from 'Utilities/Array/sortByProp';
export interface NotificationModel extends Provider {
enable: boolean;
onGrab: boolean;
onDownload: boolean;
onUpgrade: boolean;
onImportComplete: boolean;
onRename: boolean;
onSeriesAdd: boolean;
onSeriesDelete: boolean;
onEpisodeFileDelete: boolean;
onEpisodeFileDeleteForUpgrade: boolean;
onHealthIssue: boolean;
includeHealthWarnings: boolean;
onHealthRestored: boolean;
onApplicationUpdate: boolean;
onManualInteractionRequired: boolean;
supportsOnGrab: boolean;
supportsOnDownload: boolean;
supportsOnUpgrade: boolean;
supportsOnImportComplete: boolean;
supportsOnRename: boolean;
supportsOnSeriesAdd: boolean;
supportsOnSeriesDelete: boolean;
supportsOnEpisodeFileDelete: boolean;
supportsOnEpisodeFileDeleteForUpgrade: boolean;
supportsOnHealthIssue: boolean;
supportsOnHealthRestored: boolean;
supportsOnApplicationUpdate: boolean;
supportsOnManualInteractionRequired: boolean;
tags: number[];
}
const PATH = '/connection';
export const useConnectionsWithIds = (ids: number[]) => {
const allNotifications = useConnectionsData();
return allNotifications.filter((notification) =>
ids.includes(notification.id)
);
};
export const useConnection = (id: number | undefined) => {
const { data } = useConnections();
if (id === undefined) {
return undefined;
}
return data.find((notification) => notification.id === id);
};
export const useConnectionsData = () => {
const { data } = useConnections();
return data;
};
export const useSortedConnections = () => {
const { data } = useConnections();
return useMemo(() => data.sort(sortByProp('name')), [data]);
};
export const useConnections = () => {
return useProviderSettings<NotificationModel>({
path: PATH,
});
};
export const useManageConnection = (
id: number | undefined,
selectedSchema?: SelectedSchema
) => {
const schema = useSelectedSchema<NotificationModel>(PATH, selectedSchema);
if (selectedSchema && !schema) {
throw new Error('A selected schema is required to manage a notification');
}
const manage = useManageProviderSettings<NotificationModel>(
id,
selectedSchema && schema
? {
...schema,
name: schema.implementationName || '',
onGrab: schema.supportsOnGrab || false,
onDownload: schema.supportsOnDownload || false,
onUpgrade: schema.supportsOnUpgrade || false,
onImportComplete: schema.supportsOnImportComplete || false,
onRename: schema.supportsOnRename || false,
onSeriesAdd: schema.supportsOnSeriesAdd || false,
onSeriesDelete: schema.supportsOnSeriesDelete || false,
onEpisodeFileDelete: schema.supportsOnEpisodeFileDelete || false,
onEpisodeFileDeleteForUpgrade:
schema.supportsOnEpisodeFileDeleteForUpgrade || false,
onApplicationUpdate: schema.supportsOnApplicationUpdate || false,
onManualInteractionRequired:
schema.supportsOnManualInteractionRequired || false,
}
: ({} as NotificationModel),
PATH
);
return manage;
};
export const useDeleteConnection = (id: number) => {
const result = useDeleteProvider<NotificationModel>(id, PATH);
return {
...result,
deleteConnection: result.deleteProvider,
};
};
export const useConnectionSchema = (enabled: boolean = true) => {
return useProviderSchema<NotificationModel>(PATH, enabled);
};

View file

@ -12,6 +12,7 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import { useConnectionsWithIds } from 'Settings/Notifications/useConnections';
import { useReleaseProfilesWithIds } from 'Settings/Profiles/Release/useReleaseProfiles';
import translate from 'Utilities/String/translate';
import TagDetailsDelayProfile from './TagDetailsDelayProfile';
@ -96,14 +97,8 @@ function TagDetailsModalContent({
)
);
const notifications = useSelector(
createMatchingItemSelector(
notificationIds,
(state: AppState) => state.settings.notifications.items
)
);
const releaseProfiles = useReleaseProfilesWithIds(releaseProfileIds);
const notifications = useConnectionsWithIds(notificationIds);
const indexers = useSelector(
createMatchingItemSelector(

View file

@ -5,12 +5,13 @@ import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { kinds } from 'Helpers/Props';
import { useConnections } from 'Settings/Notifications/useConnections';
import { useReleaseProfiles } from 'Settings/Profiles/Release/useReleaseProfiles';
import {
fetchDelayProfiles,
fetchDownloadClients,
fetchImportLists,
fetchIndexers,
fetchNotifications,
} from 'Store/Actions/settingsActions';
import useTagDetails from 'Tags/useTagDetails';
import useTags, { useSortedTagList } from 'Tags/useTags';
@ -30,10 +31,12 @@ function Tags() {
error: detailsError,
} = useTagDetails();
useReleaseProfiles();
useConnections();
useEffect(() => {
dispatch(fetchDelayProfiles());
dispatch(fetchImportLists());
dispatch(fetchNotifications());
dispatch(fetchIndexers());
dispatch(fetchDownloadClients());

View file

@ -0,0 +1,75 @@
import { useMemo } from 'react';
import ModelBase from 'App/ModelBase';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import Provider from 'typings/Provider';
type ProviderWithPresets<T> = T & {
presets: T[];
};
export interface SelectedSchema {
implementation: string;
implementationName: string;
presetName?: string;
}
export const useProviderSchema = <T extends ModelBase>(
path: string,
enabled: boolean = true
) => {
const { isFetching, isFetched, error, data } = useApiQuery<T[]>({
path: `${path}/schema`,
queryOptions: {
enabled,
},
});
return {
isSchemaFetching: isFetching,
isSchemaFetched: isFetched,
schemaError: error,
schema: data ?? ([] as T[]),
};
};
export const useSelectedSchema = <T extends Provider>(
path: string,
selectedSchema: SelectedSchema | undefined
) => {
const { schema } = useProviderSchema<T>(path, selectedSchema != null);
return useMemo(() => {
if (!selectedSchema) {
return undefined;
}
const selected = schema.find(
(s: T) => s.implementation === selectedSchema.implementation
);
if (!selected) {
throw new Error(
`Schema with implementation ${selectedSchema.implementation} not found`
);
}
if (selectedSchema.presetName == null) {
return selected;
}
const preset =
'presets' in selected
? (selected as ProviderWithPresets<T>).presets?.find(
(p: T & { name: string }) => p.name === selectedSchema.presetName
)
: undefined;
if (!preset) {
throw new Error(
`Preset with name ${selectedSchema.presetName} not found for implementation ${selectedSchema.implementation}`
);
}
return preset;
}, [schema, selectedSchema]);
};

View file

@ -1,12 +1,37 @@
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import ModelBase from 'App/ModelBase';
import useApiMutation from 'Helpers/Hooks/useApiMutation';
import useApiQuery, { QueryOptions } from 'Helpers/Hooks/useApiQuery';
import { usePendingChangesStore } from 'Helpers/Hooks/usePendingChangesStore';
import { usePendingFieldsStore } from 'Helpers/Hooks/usePendingFieldsStore';
import selectSettings from 'Store/Selectors/selectSettings';
import { PendingSection } from 'typings/pending';
import Provider from 'typings/Provider';
import { ApiError } from 'Utilities/Fetch/fetchJson';
export const useProvider = <T extends ModelBase>(
interface ManageProviderSettings<T extends ModelBase>
extends Omit<ReturnType<typeof selectSettings<T>>, 'settings'> {
item: PendingSection<T>;
updateValue: <K extends keyof T>(key: K, value: T[K]) => void;
saveProvider: () => void;
isSaving: boolean;
saveError: ApiError | null;
testProvider: () => void;
isTesting: boolean;
updateFieldValue?: (fieldProperties: Record<string, unknown>) => void;
}
const isProviderWithFields = (provider: unknown): provider is Provider => {
return (
typeof provider === 'object' &&
provider !== null &&
'fields' in provider &&
Array.isArray((provider as Provider).fields)
);
};
export const useProviderWithDefault = <T extends ModelBase>(
id: number | undefined,
defaultProvider: T,
path: string
@ -42,7 +67,8 @@ export const useProviderSettings = <T extends ModelBase>(
export const useSaveProviderSettings = <T extends ModelBase>(
id: number,
path: string,
onSuccess?: () => void
onSuccess?: (updatedSettings: T) => void,
onError?: (error: ApiError) => void
) => {
const queryClient = useQueryClient();
@ -60,8 +86,9 @@ export const useSaveProviderSettings = <T extends ModelBase>(
return [...oldData, updatedSettings];
});
onSuccess?.();
onSuccess?.(updatedSettings);
},
onError,
},
});
@ -72,12 +99,34 @@ export const useSaveProviderSettings = <T extends ModelBase>(
};
};
export const useTestProvider = <T extends ModelBase>(
path: string,
onSuccess?: () => void,
onError?: (error: ApiError) => void
) => {
const { mutate, isPending, error } = useApiMutation<void, T>({
path: `${path}/test`,
method: 'POST',
mutationOptions: {
onSuccess,
onError,
},
});
return {
test: mutate,
isTesting: isPending,
testError: error,
};
};
export const useManageProviderSettings = <T extends ModelBase>(
id: number | undefined,
defaultProvider: T,
path: string
) => {
const provider = useProvider<T>(id, defaultProvider, path);
): ManageProviderSettings<T> => {
const provider = useProviderWithDefault<T>(id, defaultProvider, path);
const [mutationError, setMutationError] = useState<ApiError | null>(null);
const {
pendingChanges,
@ -86,24 +135,111 @@ export const useManageProviderSettings = <T extends ModelBase>(
clearPendingChanges,
} = usePendingChangesStore<T>({});
const { save, isSaving, saveError } = useSaveProviderSettings<T>(
const {
pendingFields,
setPendingFields,
clearPendingFields,
hasPendingFields,
} = usePendingFieldsStore();
const handleSaveSuccess = useCallback(() => {
setMutationError(null);
clearPendingChanges();
clearPendingFields();
}, [clearPendingChanges, clearPendingFields]);
const handleTestSuccess = useCallback(() => {
setMutationError(null);
}, []);
const { save, isSaving } = useSaveProviderSettings<T>(
provider.id,
path,
clearPendingChanges
handleSaveSuccess,
setMutationError
);
const { test, isTesting } = useTestProvider<T>(
path,
handleTestSuccess,
setMutationError
);
const { settings: item, ...settings } = useMemo(() => {
return selectSettings<T>(provider, pendingChanges, saveError);
}, [provider, pendingChanges, saveError]);
// Create a combined pending changes object that includes fields
const combinedPendingChanges = hasPendingFields
? {
...pendingChanges,
fields: Object.fromEntries(pendingFields),
}
: pendingChanges;
return selectSettings<T>(provider, combinedPendingChanges, mutationError);
}, [
provider,
pendingChanges,
pendingFields,
hasPendingFields,
mutationError,
]);
const saveProvider = useCallback(() => {
const updatedSettings = {
let updatedSettings: T = {
...provider,
...pendingChanges,
};
// If there are pending field changes and the provider has fields
if (isProviderWithFields(provider)) {
const fields = provider.fields.map((field) => {
if (pendingFields.has(field.name)) {
return {
name: field.name,
value: pendingFields.get(field.name),
};
}
return {
name: field.name,
value: field.value,
};
});
updatedSettings = {
...updatedSettings,
fields,
} as T;
}
save(updatedSettings);
}, [provider, pendingChanges, save]);
}, [provider, pendingChanges, pendingFields, save]);
const testProvider = useCallback(() => {
let updatedSettings: T = {
...provider,
...pendingChanges,
};
// If there are pending field changes and the provider has fields
if (isProviderWithFields(provider)) {
const fields = provider.fields.map((field) => {
if (pendingFields.has(field.name)) {
return {
...field,
value: pendingFields.get(field.name),
};
}
return field;
});
updatedSettings = {
...updatedSettings,
fields,
} as T;
}
test(updatedSettings);
}, [provider, pendingChanges, pendingFields, test]);
const updateValue = useCallback(
<K extends keyof T>(key: K, value: T[K]) => {
@ -116,14 +252,54 @@ export const useManageProviderSettings = <T extends ModelBase>(
[provider, setPendingChange, unsetPendingChange]
);
return {
const hasFields = useMemo(() => {
return 'fields' in provider && Array.isArray(provider.fields);
}, [provider]);
const updateFieldValue = useCallback(
(fieldProperties: Record<string, unknown>) => {
if (!isProviderWithFields(provider)) {
throw new Error('updateFieldValue called on provider without fields');
}
const providerFields = provider.fields;
const currentFields = pendingFields;
const newFields = { ...currentFields, ...fieldProperties };
// Check if the new fields are different from the provider's current fields
const hasChanges = Object.entries(newFields).some(([key, value]) => {
const currentField = providerFields.find((f) => f.name === key);
return currentField?.value !== value;
});
if (hasChanges) {
setPendingFields(newFields);
} else {
clearPendingFields();
}
},
[pendingFields, provider, setPendingFields, clearPendingFields]
);
const baseReturn = {
...settings,
item,
updateValue,
saveProvider,
isSaving,
saveError,
saveError: mutationError,
testProvider,
isTesting,
};
if (hasFields) {
return {
...baseReturn,
updateFieldValue,
};
}
return baseReturn;
};
export const useDeleteProvider = <T extends ModelBase>(

View file

@ -1,133 +0,0 @@
import { createAction } from 'redux-actions';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler';
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createSetProviderFieldValuesReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValuesReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
import selectProviderSchema from 'Utilities/State/selectProviderSchema';
//
// Variables
const section = 'settings.notifications';
//
// Actions Types
export const FETCH_NOTIFICATIONS = 'settings/notifications/fetchNotifications';
export const FETCH_NOTIFICATION_SCHEMA = 'settings/notifications/fetchNotificationSchema';
export const SELECT_NOTIFICATION_SCHEMA = 'settings/notifications/selectNotificationSchema';
export const SET_NOTIFICATION_VALUE = 'settings/notifications/setNotificationValue';
export const SET_NOTIFICATION_FIELD_VALUE = 'settings/notifications/setNotificationFieldValue';
export const SET_NOTIFICATION_FIELD_VALUES = 'settings/notifications/setNotificationFieldValues';
export const SAVE_NOTIFICATION = 'settings/notifications/saveNotification';
export const CANCEL_SAVE_NOTIFICATION = 'settings/notifications/cancelSaveNotification';
export const DELETE_NOTIFICATION = 'settings/notifications/deleteNotification';
export const TEST_NOTIFICATION = 'settings/notifications/testNotification';
export const CANCEL_TEST_NOTIFICATION = 'settings/notifications/cancelTestNotification';
//
// Action Creators
export const fetchNotifications = createThunk(FETCH_NOTIFICATIONS);
export const fetchNotificationSchema = createThunk(FETCH_NOTIFICATION_SCHEMA);
export const selectNotificationSchema = createAction(SELECT_NOTIFICATION_SCHEMA);
export const saveNotification = createThunk(SAVE_NOTIFICATION);
export const cancelSaveNotification = createThunk(CANCEL_SAVE_NOTIFICATION);
export const deleteNotification = createThunk(DELETE_NOTIFICATION);
export const testNotification = createThunk(TEST_NOTIFICATION);
export const cancelTestNotification = createThunk(CANCEL_TEST_NOTIFICATION);
export const setNotificationValue = createAction(SET_NOTIFICATION_VALUE, (payload) => {
return {
section,
...payload
};
});
export const setNotificationFieldValue = createAction(SET_NOTIFICATION_FIELD_VALUE, (payload) => {
return {
section,
...payload
};
});
export const setNotificationFieldValues = createAction(SET_NOTIFICATION_FIELD_VALUES, (payload) => {
return {
section,
...payload
};
});
//
// Details
export default {
//
// State
defaultState: {
isFetching: false,
isPopulated: false,
error: null,
isSchemaFetching: false,
isSchemaPopulated: false,
schemaError: null,
schema: [],
selectedSchema: {},
isSaving: false,
saveError: null,
isTesting: false,
items: [],
pendingChanges: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_NOTIFICATIONS]: createFetchHandler(section, '/notification'),
[FETCH_NOTIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/notification/schema'),
[SAVE_NOTIFICATION]: createSaveProviderHandler(section, '/notification'),
[CANCEL_SAVE_NOTIFICATION]: createCancelSaveProviderHandler(section),
[DELETE_NOTIFICATION]: createRemoveItemHandler(section, '/notification'),
[TEST_NOTIFICATION]: createTestProviderHandler(section, '/notification'),
[CANCEL_TEST_NOTIFICATION]: createCancelTestProviderHandler(section)
},
//
// Reducers
reducers: {
[SET_NOTIFICATION_VALUE]: createSetSettingValueReducer(section),
[SET_NOTIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
[SET_NOTIFICATION_FIELD_VALUES]: createSetProviderFieldValuesReducer(section),
[SELECT_NOTIFICATION_SCHEMA]: (state, { payload }) => {
return selectProviderSchema(state, section, payload, (selectedSchema) => {
selectedSchema.name = selectedSchema.implementationName;
selectedSchema.onGrab = selectedSchema.supportsOnGrab;
selectedSchema.onDownload = selectedSchema.supportsOnDownload;
selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade;
selectedSchema.onImportComplete = selectedSchema.supportsOnImportComplete;
selectedSchema.onRename = selectedSchema.supportsOnRename;
selectedSchema.onSeriesAdd = selectedSchema.supportsOnSeriesAdd;
selectedSchema.onSeriesDelete = selectedSchema.supportsOnSeriesDelete;
selectedSchema.onEpisodeFileDelete = selectedSchema.supportsOnEpisodeFileDelete;
selectedSchema.onEpisodeFileDeleteForUpgrade = selectedSchema.supportsOnEpisodeFileDeleteForUpgrade;
selectedSchema.onApplicationUpdate = selectedSchema.supportsOnApplicationUpdate;
selectedSchema.onManualInteractionRequired = selectedSchema.supportsOnManualInteractionRequired;
return selectedSchema;
});
}
}
};

View file

@ -19,7 +19,6 @@ import mediaManagement from './Settings/mediaManagement';
import metadata from './Settings/metadata';
import naming from './Settings/naming';
import namingExamples from './Settings/namingExamples';
import notifications from './Settings/notifications';
export * from './Settings/autoTaggingSpecifications';
export * from './Settings/autoTaggings';
@ -40,7 +39,6 @@ export * from './Settings/mediaManagement';
export * from './Settings/metadata';
export * from './Settings/naming';
export * from './Settings/namingExamples';
export * from './Settings/notifications';
//
// Variables
@ -70,8 +68,7 @@ export const defaultState = {
mediaManagement: mediaManagement.defaultState,
metadata: metadata.defaultState,
naming: naming.defaultState,
namingExamples: namingExamples.defaultState,
notifications: notifications.defaultState
namingExamples: namingExamples.defaultState
};
export const persistState = [
@ -100,8 +97,7 @@ export const actionHandlers = handleThunks({
...mediaManagement.actionHandlers,
...metadata.actionHandlers,
...naming.actionHandlers,
...namingExamples.actionHandlers,
...notifications.actionHandlers
...namingExamples.actionHandlers
});
//
@ -126,7 +122,6 @@ export const reducers = createHandleActions({
...mediaManagement.reducers,
...metadata.reducers,
...naming.reducers,
...namingExamples.reducers,
...notifications.reducers
...namingExamples.reducers
}, defaultState, section);

View file

@ -1,35 +0,0 @@
import Provider from './Provider';
interface Notification extends Provider {
enable: boolean;
onGrab: boolean;
onDownload: boolean;
onUpgrade: boolean;
onImportComplete: boolean;
onRename: boolean;
onSeriesAdd: boolean;
onSeriesDelete: boolean;
onEpisodeFileDelete: boolean;
onEpisodeFileDeleteForUpgrade: boolean;
onHealthIssue: boolean;
includeHealthWarnings: boolean;
onHealthRestored: boolean;
onApplicationUpdate: boolean;
onManualInteractionRequired: boolean;
supportsOnGrab: boolean;
supportsOnDownload: boolean;
supportsOnUpgrade: boolean;
supportsOnImportComplete: boolean;
supportsOnRename: boolean;
supportsOnSeriesAdd: boolean;
supportsOnSeriesDelete: boolean;
supportsOnEpisodeFileDelete: boolean;
supportsOnEpisodeFileDeleteForUpgrade: boolean;
supportsOnHealthIssue: boolean;
supportsOnHealthRestored: boolean;
supportsOnApplicationUpdate: boolean;
supportsOnManualInteractionRequired: boolean;
tags: number[];
}
export default Notification;