Use react-query for tags

New: Show error when tag cannot be created
Closes #7796
This commit is contained in:
Mark McDowall 2025-11-14 18:52:05 -08:00
parent 20ad1b4410
commit 0809a72ce5
40 changed files with 541 additions and 426 deletions

View file

@ -20,6 +20,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Popover from 'Components/Tooltip/Popover';
import { getValidationFailures } from 'Helpers/Hooks/useApiMutation';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import { SeriesType } from 'Series/Series';
import SeriesPoster from 'Series/SeriesPoster';
@ -50,7 +51,10 @@ function AddNewSeriesModalContent({
const { isAdding, addError, addSeries } = useAddSeries();
const { settings, validationErrors, validationWarnings } = useMemo(() => {
return selectSettings(options, {}, addError);
return {
...selectSettings(options, {}),
...getValidationFailures(addError),
};
}, [options, addError]);
const [seriesType, setSeriesType] = useState<SeriesType>(

View file

@ -20,7 +20,6 @@ import ReleasesAppState from './ReleasesAppState';
import RootFolderAppState from './RootFolderAppState';
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
import SettingsAppState from './SettingsAppState';
import TagsAppState from './TagsAppState';
export interface FilterBuilderPropOption {
id: string;
@ -97,7 +96,6 @@ interface AppState {
seriesHistory: SeriesHistoryAppState;
seriesIndex: SeriesIndexAppState;
settings: SettingsAppState;
tags: TagsAppState;
}
export default AppState;

View file

@ -1,25 +1,9 @@
import ModelBase from 'App/ModelBase';
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
export interface Tag extends ModelBase {
label: string;
}
export interface TagDetail extends ModelBase {
label: string;
autoTagIds: number[];
delayProfileIds: number[];
downloadClientIds: [];
importListIds: number[];
indexerIds: number[];
notificationIds: number[];
restrictionIds: number[];
excludedReleaseProfileIds: number[];
seriesIds: number[];
}
import { TagDetail } from 'Tags/useTagDetails';
import { Tag } from 'Tags/useTags';
export interface TagDetailAppState
extends AppSectionState<TagDetail>,

View file

@ -1,6 +1,5 @@
import React, { useMemo } from 'react';
import { useSelector } from 'react-redux';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { useTagList } from 'Tags/useTags';
import FilterBuilderRowValue, {
FilterBuilderRowValueProps,
} from './FilterBuilderRowValue';
@ -11,7 +10,7 @@ type TagFilterBuilderRowValueProps<T> = Omit<
>;
function TagFilterBuilderRowValue<T>(props: TagFilterBuilderRowValueProps<T>) {
const tags = useSelector(createTagsSelector());
const tags = useTagList();
const tagList = useMemo(() => {
return tags.map((tag) => {

View file

@ -1,4 +1,4 @@
import React, { ElementType, ReactNode } from 'react';
import React, { ElementType, ReactNode, useMemo, useState } from 'react';
import Link from 'Components/Link/Link';
import { inputTypes } from 'Helpers/Props';
import { InputType } from 'Helpers/Props/inputTypes';
@ -9,6 +9,7 @@ import CaptchaInput, { CaptchaInputProps } from './CaptchaInput';
import CheckInput, { CheckInputProps } from './CheckInput';
import FloatInput, { FloatInputProps } from './FloatInput';
import { FormInputButtonProps } from './FormInputButton';
import { FormInputGroupProvider } from './FormInputGroupContext';
import FormInputHelpText from './FormInputHelpText';
import KeyValueListInput, { KeyValueListInputProps } from './KeyValueListInput';
import NumberInput, { NumberInputProps } from './NumberInput';
@ -206,11 +207,27 @@ function FormInputGroup<T, C extends InputType>(
helpTextWarning,
helpLink,
pending,
errors = [],
warnings = [],
errors: serverErrors = [],
warnings: serverWarnings = [],
...otherProps
} = props;
const [clientErrors, setClientErrors] = useState<
(ValidationMessage | ValidationError)[]
>([]);
const [clientWarnings, setClientWarnings] = useState<
(ValidationMessage | ValidationWarning)[]
>([]);
const errors = useMemo(() => {
return [...clientErrors, ...serverErrors];
}, [clientErrors, serverErrors]);
const warnings = useMemo(() => {
return [...clientWarnings, ...serverWarnings];
}, [clientWarnings, serverWarnings]);
const InputComponent = componentMap[type];
const checkInput = type === inputTypes.CHECK;
const hasError = !!errors.length;
@ -220,6 +237,10 @@ function FormInputGroup<T, C extends InputType>(
const hasButton = !!buttonsArray.length;
return (
<FormInputGroupProvider
setClientErrors={setClientErrors}
setClientWarnings={setClientWarnings}
>
<div className={containerClassName}>
<div className={className}>
<div className={styles.inputContainer}>
@ -331,6 +352,7 @@ function FormInputGroup<T, C extends InputType>(
);
})}
</div>
</FormInputGroupProvider>
);
}

View file

@ -0,0 +1,44 @@
import React, { createContext, PropsWithChildren, useMemo } from 'react';
import { ValidationError, ValidationWarning } from 'typings/pending';
import { ValidationMessage } from './FormInputGroup';
interface FormInputGroupProviderProps extends PropsWithChildren {
setClientErrors: (errors: (ValidationMessage | ValidationError)[]) => void;
setClientWarnings: (
warnings: (ValidationMessage | ValidationWarning)[]
) => void;
}
interface FormInputGroupContextProps {
setClientErrors: (errors: (ValidationMessage | ValidationError)[]) => void;
setClientWarnings: (
warnings: (ValidationMessage | ValidationWarning)[]
) => void;
}
const FormInputGroupContext = createContext<
FormInputGroupContextProps | undefined
>(undefined);
export function FormInputGroupProvider({
setClientErrors,
setClientWarnings,
children,
}: FormInputGroupProviderProps) {
const value = useMemo(() => {
return {
setClientErrors,
setClientWarnings,
};
}, [setClientErrors, setClientWarnings]);
return (
<FormInputGroupContext.Provider value={value}>
{children}
</FormInputGroupContext.Provider>
);
}
export function useFormInputGroup() {
return React.useContext(FormInputGroupContext);
}

View file

@ -1,10 +1,7 @@
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { addTag } from 'Store/Actions/tagActions';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import React, { useCallback, useEffect, useMemo } from 'react';
import { Tag, useAddTag, useSortedTagList } from 'Tags/useTags';
import { InputChanged } from 'typings/inputs';
import sortByProp from 'Utilities/Array/sortByProp';
import { useFormInputGroup } from '../FormInputGroupContext';
import TagInput, { TagBase, TagInputProps } from './TagInput';
interface SeriesTag extends TagBase {
@ -22,24 +19,13 @@ export interface SeriesTagInputProps<V>
onChange: (change: InputChanged<V>) => void;
}
const VALID_TAG_REGEX = new RegExp('[^-_a-z0-9]', 'i');
function isValidTag(tagName: string) {
try {
return !VALID_TAG_REGEX.test(tagName);
} catch {
return false;
}
}
function createSeriesTagsSelector(tags: number[]) {
return createSelector(createTagsSelector(), (tagList) => {
const sortedTags = tagList.sort(sortByProp('label'));
function useSeriesTags(tags: number[]) {
const sortedTags = useSortedTagList();
const filteredTagList = sortedTags.filter((tag) => !tags.includes(tag.id));
return {
tags: tags.reduce((acc: SeriesTag[], tag) => {
const matchingTag = tagList.find((t) => t.id === tag);
const matchingTag = sortedTags.find((t) => t.id === tag);
if (matchingTag) {
acc.push({
@ -60,7 +46,6 @@ function createSeriesTagsSelector(tags: number[]) {
allTags: sortedTags,
};
});
}
export default function SeriesTagInput<V extends number | number[]>({
@ -69,7 +54,7 @@ export default function SeriesTagInput<V extends number | number[]>({
onChange,
...otherProps
}: SeriesTagInputProps<V>) {
const dispatch = useDispatch();
const formInputActions = useFormInputGroup();
const isArray = Array.isArray(value);
const arrayValue = useMemo(() => {
@ -80,12 +65,10 @@ export default function SeriesTagInput<V extends number | number[]>({
return value === 0 ? [] : [value as number];
}, [isArray, value]);
const { tags, tagList, allTags } = useSelector(
createSeriesTagsSelector(arrayValue)
);
const { tags, tagList, allTags } = useSeriesTags(arrayValue);
const handleTagCreated = useCallback(
(tag: SeriesTag) => {
(tag: Tag) => {
if (isArray) {
onChange({ name, value: [...value, tag.id] as V });
} else {
@ -98,6 +81,8 @@ export default function SeriesTagInput<V extends number | number[]>({
[name, value, isArray, onChange]
);
const { addTag, addTagError } = useAddTag(handleTagCreated);
const handleTagAdd = useCallback(
(newTag: SeriesTag) => {
if (newTag.id) {
@ -112,16 +97,13 @@ export default function SeriesTagInput<V extends number | number[]>({
const existingTag = allTags.some((t) => t.label === newTag.name);
if (isValidTag(newTag.name) && !existingTag) {
dispatch(
if (!existingTag) {
addTag({
tag: { label: newTag.name },
onTagCreated: handleTagCreated,
})
);
label: newTag.name,
});
}
},
[name, value, isArray, allTags, handleTagCreated, onChange, dispatch]
[name, value, isArray, allTags, onChange, addTag]
);
const handleTagDelete = useCallback(
@ -138,6 +120,15 @@ export default function SeriesTagInput<V extends number | number[]>({
[name, value, isArray, onChange]
);
useEffect(() => {
formInputActions?.setClientErrors(addTagError?.errors ?? []);
formInputActions?.setClientWarnings(addTagError?.warnings ?? []);
}, [addTagError, formInputActions]);
useEffect(() => {
console.info('\x1b[36m[MarkTest] formInputActions has changed\x1b[0m');
}, [formInputActions]);
return (
<TagInput
{...otherProps}

View file

@ -11,7 +11,7 @@ interface ErrorPageProps {
translationsError?: Error;
seriesError?: Error;
customFiltersError?: Error;
tagsError?: Error;
tagsError: ApiError | null;
qualityProfilesError?: Error;
uiSettingsError?: Error;
systemStatusError: ApiError | null;

View file

@ -14,7 +14,6 @@ import Autosuggest from 'react-autosuggest';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useDebouncedCallback } from 'use-debounce';
import { Tag } from 'App/State/TagsAppState';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
@ -22,7 +21,7 @@ import { icons } from 'Helpers/Props';
import Series from 'Series/Series';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { Tag, useTagList } from 'Tags/useTags';
import translate from 'Utilities/String/translate';
import SeriesSearchResult from './SeriesSearchResult';
import styles from './SeriesSearchInput.css';
@ -70,11 +69,8 @@ interface Section {
suggestions: SeriesSuggestion[] | AddNewSeriesSuggestion[];
}
function createUnoptimizedSelector() {
return createSelector(
createAllSeriesSelector(),
createTagsSelector(),
(allSeries, allTags) => {
function createUnoptimizedSelector(tagList: Tag[]) {
return createSelector(createAllSeriesSelector(), (allSeries) => {
return allSeries.map((series): SuggestedSeries => {
const {
title,
@ -101,7 +97,7 @@ function createUnoptimizedSelector() {
tmdbId,
firstCharacter: title.charAt(0).toLowerCase(),
tags: tags.reduce<Tag[]>((acc, id) => {
const matchingTag = allTags.find((tag) => tag.id === id);
const matchingTag = tagList.find((tag) => tag.id === id);
if (matchingTag) {
acc.push(matchingTag);
@ -111,19 +107,19 @@ function createUnoptimizedSelector() {
}, []),
};
});
}
);
});
}
function createSeriesSelector() {
function createSeriesSelector(tagList: Tag[]) {
return createDeepEqualSelector(
createUnoptimizedSelector(),
createUnoptimizedSelector(tagList),
(series) => series
);
}
function SeriesSearchInput() {
const series = useSelector(createSeriesSelector());
const tagList = useTagList();
const series = useSelector(createSeriesSelector(tagList));
const dispatch = useDispatch();
const { bindShortcut, unbindShortcut } = useKeyboardShortcuts();

View file

@ -1,8 +1,8 @@
import React from 'react';
import { Tag } from 'App/State/TagsAppState';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
import SeriesPoster from 'Series/SeriesPoster';
import { Tag } from 'Tags/useTags';
import { SuggestedSeries } from './SeriesSearchInput';
import styles from './SeriesSearchResult.css';

View file

@ -3,11 +3,12 @@ import { Error } from 'App/State/AppSectionState';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { kinds } from 'Helpers/Props';
import { ApiError } from 'Utilities/Fetch/fetchJson';
interface PageSectionContentProps {
isFetching: boolean;
isPopulated: boolean;
error?: Error;
error?: Error | ApiError | null;
errorMessage: string;
children: React.ReactNode;
}

View file

@ -1,6 +1,5 @@
import React from 'react';
import { useSelector } from 'react-redux';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { useTagList } from 'Tags/useTags';
import TagList from './TagList';
interface SeriesTagListProps {
@ -8,7 +7,7 @@ interface SeriesTagListProps {
}
function SeriesTagList({ tags }: SeriesTagListProps) {
const tagList = useSelector(createTagsSelector());
const tagList = useTagList();
return <TagList tags={tags} tagList={tagList} />;
}

View file

@ -20,7 +20,6 @@ import {
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { fetchSeries } from 'Store/Actions/seriesActions';
import { fetchQualityDefinitions } from 'Store/Actions/settingsActions';
import { fetchTagDetails, fetchTags } from 'Store/Actions/tagActions';
import { repopulatePage } from 'Utilities/pagePopulator';
import SignalRLogger from 'Utilities/SignalRLogger';
@ -303,11 +302,13 @@ function SignalRListener() {
}
if (name === 'tag') {
if (body.action === 'sync') {
dispatch(fetchTags());
dispatch(fetchTagDetails());
if (version < 5 || body.action !== 'sync') {
return;
}
queryClient.invalidateQueries({ queryKey: ['/tag'] });
queryClient.invalidateQueries({ queryKey: ['/tag/detail'] });
return;
}

View file

@ -1,7 +1,7 @@
import React from 'react';
import { Tag } from 'App/State/TagsAppState';
import { kinds } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { Tag } from 'Tags/useTags';
import sortByProp from 'Utilities/Array/sortByProp';
import Label, { LabelProps } from './Label';
import styles from './TagList.css';

View file

@ -1,14 +1,22 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useMemo } from 'react';
import { Error } from 'App/State/AppSectionState';
import fetchJson, { FetchJsonOptions } from 'Utilities/Fetch/fetchJson';
import { ValidationFailures } from 'Store/Selectors/selectSettings';
import {
ValidationError,
ValidationFailure,
ValidationWarning,
} from 'typings/pending';
import fetchJson, {
ApiError,
FetchJsonOptions,
} from 'Utilities/Fetch/fetchJson';
import getQueryPath from 'Utilities/Fetch/getQueryPath';
import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString';
interface MutationOptions<T, TData>
extends Omit<FetchJsonOptions<TData>, 'method'> {
method: 'POST' | 'PUT' | 'DELETE';
mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>;
mutationOptions?: Omit<UseMutationOptions<T, ApiError, TData>, 'mutationFn'>;
queryParams?: QueryParams;
}
@ -25,7 +33,7 @@ function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
};
}, [options]);
return useMutation<T, Error, TData>({
return useMutation<T, ApiError, TData>({
...options.mutationOptions,
mutationFn: async (data?: TData) => {
const { path, ...otherOptions } = requestOptions;
@ -36,3 +44,30 @@ function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
}
export default useApiMutation;
export function getValidationFailures(
error?: ApiError | null
): ValidationFailures {
if (!error || error.statusCode !== 400) {
return {
errors: [],
warnings: [],
};
}
return ((error.statusBody ?? []) as ValidationFailure[]).reduce(
(acc: ValidationFailures, failure: ValidationFailure) => {
if (failure.isWarning) {
acc.warnings.push(failure as ValidationWarning);
} else {
acc.errors.push(failure as ValidationError);
}
return acc;
},
{
errors: [],
warnings: [],
}
);
}

View file

@ -12,19 +12,20 @@ import {
fetchQualityProfiles,
fetchUISettings,
} from 'Store/Actions/settingsActions';
import { fetchTags } from 'Store/Actions/tagActions';
import useSystemStatus from 'System/Status/useSystemStatus';
import useTags from 'Tags/useTags';
import { ApiError } from 'Utilities/Fetch/fetchJson';
const createErrorsSelector = ({
systemStatusError,
tagsError,
}: {
systemStatusError: ApiError | null;
tagsError: ApiError | null;
}) =>
createSelector(
(state: AppState) => state.series.error,
(state: AppState) => state.customFilters.error,
(state: AppState) => state.tags.error,
(state: AppState) => state.settings.ui.error,
(state: AppState) => state.settings.qualityProfiles.error,
(state: AppState) => state.settings.languages.error,
@ -34,7 +35,6 @@ const createErrorsSelector = ({
(
seriesError,
customFiltersError,
tagsError,
uiSettingsError,
qualityProfilesError,
languagesError,
@ -45,13 +45,13 @@ const createErrorsSelector = ({
const hasError = !!(
seriesError ||
customFiltersError ||
tagsError ||
uiSettingsError ||
qualityProfilesError ||
languagesError ||
importListsError ||
indexerFlagsError ||
systemStatusError ||
tagsError ||
translationsError
);
@ -78,22 +78,25 @@ const useAppPage = () => {
const { isFetched: isSystemStatusFetched, error: systemStatusError } =
useSystemStatus();
const isPopulated =
useSelector(
const { isFetched: isTagsFetched, error: tagsError } = useTags();
const isAppStatePopulated = useSelector(
(state: AppState) =>
state.series.isPopulated &&
state.customFilters.isPopulated &&
state.tags.isPopulated &&
state.settings.ui.isPopulated &&
state.settings.qualityProfiles.isPopulated &&
state.settings.languages.isPopulated &&
state.settings.importLists.isPopulated &&
state.settings.indexerFlags.isPopulated &&
state.app.translations.isPopulated
) && isSystemStatusFetched;
);
const isPopulated =
isAppStatePopulated && isSystemStatusFetched && isTagsFetched;
const { hasError, errors } = useSelector(
createErrorsSelector({ systemStatusError })
createErrorsSelector({ systemStatusError, tagsError })
);
const isLocalStorageSupported = useMemo(() => {
@ -112,7 +115,6 @@ const useAppPage = () => {
useEffect(() => {
dispatch(fetchSeries());
dispatch(fetchCustomFilters());
dispatch(fetchTags());
dispatch(fetchQualityProfiles());
dispatch(fetchLanguages());
dispatch(fetchImportLists());

View file

@ -2,7 +2,7 @@ import React from 'react';
import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import useTags from 'Tags/useTags';
import { useTagList } from 'Tags/useTags';
import sortByProp from 'Utilities/Array/sortByProp';
interface SeriesTagsProps {
@ -11,7 +11,7 @@ interface SeriesTagsProps {
function SeriesTags({ seriesId }: SeriesTagsProps) {
const series = useSeries(seriesId)!;
const tagList = useTags();
const tagList = useTagList();
const tags = series.tags
.map((tagId) => tagList.find((tag) => tag.id === tagId))

View file

@ -2,7 +2,6 @@ import { uniq } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { useSelect } from 'App/Select/SelectContext';
import { Tag } from 'App/State/TagsAppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
@ -17,7 +16,7 @@ import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import Series from 'Series/Series';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { Tag, useTagList } from 'Tags/useTags';
import translate from 'Utilities/String/translate';
import styles from './TagsModalContent.css';
@ -31,7 +30,7 @@ function TagsModalContent({
onApplyTagsPress,
}: TagsModalContentProps) {
const allSeries: Series[] = useSelector(createAllSeriesSelector());
const tagList: Tag[] = useSelector(createTagsSelector());
const tagList: Tag[] = useTagList();
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');

View file

@ -6,7 +6,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import { kinds } from 'Helpers/Props';
import { deleteDownloadClient } from 'Store/Actions/settingsActions';
import useTags from 'Tags/useTags';
import { useTagList } from 'Tags/useTags';
import translate from 'Utilities/String/translate';
import EditDownloadClientModal from './EditDownloadClientModal';
import styles from './DownloadClient.css';
@ -27,7 +27,7 @@ function DownloadClient({
tags,
}: DownloadClientProps) {
const dispatch = useDispatch();
const tagList = useTags();
const tagList = useTagList();
const [isEditDownloadClientModalOpen, setIsEditDownloadClientModalOpen] =
useState(false);

View file

@ -3,7 +3,6 @@ import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { DownloadClientAppState } from 'App/State/SettingsAppState';
import { Tag } from 'App/State/TagsAppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
@ -16,7 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { Tag, useTagList } from 'Tags/useTags';
import DownloadClient from 'typings/DownloadClient';
import translate from 'Utilities/String/translate';
import styles from './TagsModalContent.css';
@ -33,7 +32,7 @@ function TagsModalContent(props: TagsModalContentProps) {
const allDownloadClients: DownloadClientAppState = useSelector(
(state: AppState) => state.settings.downloadClients
);
const tagList: Tag[] = useSelector(createTagsSelector());
const tagList: Tag[] = useTagList();
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');

View file

@ -7,7 +7,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import { icons, kinds } from 'Helpers/Props';
import { deleteImportList } from 'Store/Actions/settingsActions';
import useTags from 'Tags/useTags';
import { useTagList } from 'Tags/useTags';
import formatShortTimeSpan from 'Utilities/Date/formatShortTimeSpan';
import translate from 'Utilities/String/translate';
import EditImportListModal from './EditImportListModal';
@ -31,7 +31,7 @@ function ImportList({
onCloneImportListPress,
}: ImportListProps) {
const dispatch = useDispatch();
const tagList = useTags();
const tagList = useTagList();
const [isEditImportListModalOpen, setIsEditImportListModalOpen] =
useState(false);

View file

@ -3,7 +3,6 @@ import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { ImportListAppState } from 'App/State/SettingsAppState';
import { Tag } from 'App/State/TagsAppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
@ -16,7 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { Tag, useTagList } from 'Tags/useTags';
import ImportList from 'typings/ImportList';
import translate from 'Utilities/String/translate';
import styles from './TagsModalContent.css';
@ -33,7 +32,7 @@ function TagsModalContent(props: TagsModalContentProps) {
const allImportLists: ImportListAppState = useSelector(
(state: AppState) => state.settings.importLists
);
const tagList: Tag[] = useSelector(createTagsSelector());
const tagList: Tag[] = useTagList();
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');

View file

@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import Card from 'Components/Card';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
@ -7,7 +7,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import { icons, kinds } from 'Helpers/Props';
import { deleteIndexer } from 'Store/Actions/settingsActions';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { useTagList } from 'Tags/useTags';
import IndexerModel from 'typings/Indexer';
import translate from 'Utilities/String/translate';
import EditIndexerModal from './EditIndexerModal';
@ -32,7 +32,7 @@ function Indexer({
onCloneIndexerPress,
}: IndexerProps) {
const dispatch = useDispatch();
const tagList = useSelector(createTagsSelector());
const tagList = useTagList();
const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false);
const [isDeleteIndexerModalOpen, setIsDeleteIndexerModalOpen] =

View file

@ -3,7 +3,6 @@ import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { IndexerAppState } from 'App/State/SettingsAppState';
import { Tag } from 'App/State/TagsAppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
@ -16,7 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { Tag, useTagList } from 'Tags/useTags';
import Indexer from 'typings/Indexer';
import translate from 'Utilities/String/translate';
import styles from './TagsModalContent.css';
@ -33,7 +32,7 @@ function TagsModalContent(props: TagsModalContentProps) {
const allIndexers: IndexerAppState = useSelector(
(state: AppState) => state.settings.indexers
);
const tagList: Tag[] = useSelector(createTagsSelector());
const tagList: Tag[] = useTagList();
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');

View file

@ -6,7 +6,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import { kinds } from 'Helpers/Props';
import { deleteNotification } from 'Store/Actions/settingsActions';
import useTags from 'Tags/useTags';
import { useTagList } from 'Tags/useTags';
import NotificationModel from 'typings/Notification';
import translate from 'Utilities/String/translate';
import EditNotificationModal from './EditNotificationModal';
@ -44,7 +44,7 @@ function Notification({
tags,
}: NotificationModel) {
const dispatch = useDispatch();
const tagList = useTags();
const tagList = useTagList();
const [isEditNotificationModalOpen, setIsEditNotificationModalOpen] =
useState(false);

View file

@ -2,7 +2,6 @@ import classNames from 'classnames';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { DragSourceMonitor, useDrag, useDrop, XYCoord } from 'react-dnd';
import { useDispatch } from 'react-redux';
import { Tag } from 'App/State/TagsAppState';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import ConfirmModal from 'Components/Modal/ConfirmModal';
@ -10,6 +9,7 @@ import TagList from 'Components/TagList';
import DragType from 'Helpers/DragType';
import { icons, kinds } from 'Helpers/Props';
import { deleteDelayProfile } from 'Store/Actions/settingsActions';
import { Tag } from 'Tags/useTags';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import EditDelayProfileModal from './EditDelayProfileModal';

View file

@ -12,7 +12,7 @@ import {
fetchDelayProfiles,
reorderDelayProfile,
} from 'Store/Actions/settingsActions';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { useTagList } from 'Tags/useTags';
import DelayProfileModel from 'typings/DelayProfile';
import translate from 'Utilities/String/translate';
import DelayProfile from './DelayProfile';
@ -60,7 +60,7 @@ function DelayProfiles() {
createDisplayProfilesSelector()
);
const tagList = useSelector(createTagsSelector());
const tagList = useTagList();
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);

View file

@ -1,6 +1,5 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { Tag } from 'App/State/TagsAppState';
import Card from 'Components/Card';
import Label from 'Components/Label';
import MiddleTruncate from 'Components/MiddleTruncate';
@ -9,6 +8,7 @@ import TagList from 'Components/TagList';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { kinds } from 'Helpers/Props';
import { deleteReleaseProfile } from 'Store/Actions/Settings/releaseProfiles';
import { Tag } from 'Tags/useTags';
import Indexer from 'typings/Indexer';
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
import translate from 'Utilities/String/translate';

View file

@ -11,7 +11,7 @@ import { icons } from 'Helpers/Props';
import { fetchIndexers } from 'Store/Actions/Settings/indexers';
import { fetchReleaseProfiles } from 'Store/Actions/Settings/releaseProfiles';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { useTagList } from 'Tags/useTags';
import translate from 'Utilities/String/translate';
import EditReleaseProfileModal from './EditReleaseProfileModal';
import ReleaseProfileItem from './ReleaseProfileItem';
@ -21,7 +21,7 @@ function ReleaseProfiles() {
const { items, isFetching, isPopulated, error }: ReleaseProfilesAppState =
useSelector(createClientSideCollectionSelector('settings.releaseProfiles'));
const tagList = useSelector(createTagsSelector());
const tagList = useTagList();
const indexerList = useSelector(
(state: AppState) => state.settings.indexers.items
);

View file

@ -1,5 +1,4 @@
import React, { useCallback, useState } from 'react';
import { Tag } from 'App/State/TagsAppState';
import Card from 'Components/Card';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
@ -7,6 +6,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import { icons, kinds } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { Tag } from 'Tags/useTags';
import { AutoTaggingSpecification } from 'typings/AutoTagging';
import translate from 'Utilities/String/translate';
import EditAutoTaggingModal from './EditAutoTaggingModal';

View file

@ -13,7 +13,7 @@ import {
fetchAutoTaggings,
} from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { useTagList } from 'Tags/useTags';
import AutoTaggingModel from 'typings/AutoTagging';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
@ -29,7 +29,7 @@ export default function AutoTaggings() {
)
);
const tagList = useSelector(createTagsSelector());
const tagList = useTagList();
const dispatch = useDispatch();
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [tagsFromId, setTagsFromId] = useState<number>();

View file

@ -1,10 +1,9 @@
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Card from 'Components/Card';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import { kinds } from 'Helpers/Props';
import { deleteTag } from 'Store/Actions/tagActions';
import createTagDetailsSelector from 'Store/Selectors/createTagDetailsSelector';
import { useTagDetail } from 'Tags/useTagDetails';
import { useDeleteTag } from 'Tags/useTags';
import translate from 'Utilities/String/translate';
import TagDetailsModal from './Details/TagDetailsModal';
import TagInUse from './TagInUse';
@ -16,18 +15,18 @@ interface TagProps {
}
function Tag({ id, label }: TagProps) {
const dispatch = useDispatch();
const { deleteTag } = useDeleteTag(id);
const {
delayProfileIds = [],
importListIds = [],
notificationIds = [],
restrictionIds = [],
excludedReleaseProfileIds = [],
indexerIds = [],
downloadClientIds = [],
autoTagIds = [],
seriesIds = [],
} = useSelector(createTagDetailsSelector(id)) ?? {};
delayProfileIds,
importListIds,
notificationIds,
restrictionIds,
excludedReleaseProfileIds,
indexerIds,
downloadClientIds,
autoTagIds,
seriesIds,
} = useTagDetail(id);
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const [isDeleteTagModalOpen, setIsDeleteTagModalOpen] = useState(false);
@ -61,8 +60,8 @@ function Tag({ id, label }: TagProps) {
}, []);
const handleConfirmDeleteTag = useCallback(() => {
dispatch(deleteTag({ id }));
}, [id, dispatch]);
deleteTag();
}, [deleteTag]);
const handleDeleteTagModalClose = useCallback(() => {
setIsDeleteTagModalOpen(false);

View file

@ -1,6 +1,5 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import TagsAppState, { Tag as TagModel } from 'App/State/TagsAppState';
import { useDispatch } from 'react-redux';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import PageSectionContent from 'Components/Page/PageSectionContent';
@ -13,31 +12,24 @@ import {
fetchNotifications,
fetchReleaseProfiles,
} from 'Store/Actions/settingsActions';
import { fetchTagDetails, fetchTags } from 'Store/Actions/tagActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByProp from 'Utilities/Array/sortByProp';
import useTagDetails from 'Tags/useTagDetails';
import useTags, { useSortedTagList } from 'Tags/useTags';
import translate from 'Utilities/String/translate';
import Tag from './Tag';
import styles from './Tags.css';
function Tags() {
const dispatch = useDispatch();
const { items, isFetching, isPopulated, error, details } = useSelector(
createSortedSectionSelector<TagModel, TagsAppState>(
'tags',
sortByProp('label')
)
);
const { isFetching, isFetched, error } = useTags();
const items = useSortedTagList();
const {
isFetching: isDetailsFetching,
isPopulated: isDetailsPopulated,
isFetched: isDetailsFetched,
error: detailsError,
} = details;
} = useTagDetails();
useEffect(() => {
dispatch(fetchTags());
dispatch(fetchTagDetails());
dispatch(fetchDelayProfiles());
dispatch(fetchImportLists());
dispatch(fetchNotifications());
@ -58,7 +50,7 @@ function Tags() {
errorMessage={translate('TagsLoadError')}
error={error || detailsError}
isFetching={isFetching || isDetailsFetching}
isPopulated={isPopulated || isDetailsPopulated}
isPopulated={isFetched && isDetailsFetched}
>
<div className={styles.tags}>
{items.map((item) => {

View file

@ -18,7 +18,6 @@ import * as series from './seriesActions';
import * as seriesHistory from './seriesHistoryActions';
import * as seriesIndex from './seriesIndexActions';
import * as settings from './settingsActions';
import * as tags from './tagActions';
export default [
app,
@ -40,6 +39,5 @@ export default [
series,
seriesHistory,
seriesIndex,
settings,
tags
settings
];

View file

@ -1,76 +0,0 @@
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { update } from './baseActions';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
//
// Variables
export const section = 'tags';
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
items: [],
details: {
isFetching: false,
isPopulated: false,
error: null,
items: []
}
};
//
// Actions Types
export const FETCH_TAGS = 'tags/fetchTags';
export const ADD_TAG = 'tags/addTag';
export const DELETE_TAG = 'tags/deleteTag';
export const FETCH_TAG_DETAILS = 'tags/fetchTagDetails';
//
// Action Creators
export const fetchTags = createThunk(FETCH_TAGS);
export const addTag = createThunk(ADD_TAG);
export const deleteTag = createThunk(DELETE_TAG);
export const fetchTagDetails = createThunk(FETCH_TAG_DETAILS);
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_TAGS]: createFetchHandler(section, '/tag'),
[ADD_TAG]: function(getState, payload, dispatch) {
const promise = createAjaxRequest({
url: '/tag',
method: 'POST',
data: JSON.stringify(payload.tag),
dataType: 'json'
}).request;
promise.done((data) => {
const tags = getState().tags.items.slice();
tags.push(data);
dispatch(update({ section, data: tags }));
payload.onTagCreated(data);
});
},
[DELETE_TAG]: createRemoveItemHandler(section, '/tag'),
[FETCH_TAG_DETAILS]: createFetchHandler('tags.details', '/tag/detail')
});
//
// Reducers
export const reducers = createHandleActions({}, defaultState, section);

View file

@ -1,13 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createTagDetailsSelector(id: number) {
return createSelector(
(state: AppState) => state.tags.details.items,
(tagDetails) => {
return tagDetails.find((t) => t.id === id);
}
);
}
export default createTagDetailsSelector;

View file

@ -1,14 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import { Tag } from 'App/State/TagsAppState';
function createTagsSelector(): (state: AppState) => Tag[] {
return createSelector(
(state: AppState) => state.tags.items,
(tags) => {
return tags;
}
);
}
export default createTagsSelector;

View file

@ -12,12 +12,14 @@ import {
} from 'typings/pending';
import isEmpty from 'Utilities/Object/isEmpty';
interface ValidationFailures {
export interface ValidationFailures {
errors: ValidationError[];
warnings: ValidationWarning[];
}
function getValidationFailures(saveError?: Error | null): ValidationFailures {
export function getValidationFailures(
saveError?: Error | null
): ValidationFailures {
if (!saveError || saveError.status !== 400) {
return {
errors: [],

View file

@ -0,0 +1,48 @@
import ModelBase from 'App/ModelBase';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
const DEFAULT_TAG_DETAILS: TagDetail[] = [];
export interface TagDetail extends ModelBase {
label: string;
autoTagIds: number[];
delayProfileIds: number[];
downloadClientIds: [];
importListIds: number[];
indexerIds: number[];
notificationIds: number[];
restrictionIds: number[];
excludedReleaseProfileIds: number[];
seriesIds: number[];
}
const useTagDetails = () => {
const { queryKey, ...result } = useApiQuery<TagDetail[]>({
path: '/tag/detail',
});
return {
...result,
data: result.data ?? DEFAULT_TAG_DETAILS,
};
};
export default useTagDetails;
export const useTagDetail = (id: number) => {
const { data: tagDetails } = useTagDetails();
return (
tagDetails.find((tagDetail) => tagDetail.id === id) ?? {
delayProfileIds: [],
importListIds: [],
notificationIds: [],
restrictionIds: [],
excludedReleaseProfileIds: [],
indexerIds: [],
downloadClientIds: [],
autoTagIds: [],
seriesIds: [],
}
);
};

View file

@ -1,8 +1,115 @@
import { useSelector } from 'react-redux';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import ModelBase from 'App/ModelBase';
import useApiMutation, {
getValidationFailures,
} from 'Helpers/Hooks/useApiMutation';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import { ValidationFailures } from 'Store/Selectors/selectSettings';
import sortByProp from 'Utilities/Array/sortByProp';
const DEFAULT_TAGS: Tag[] = [];
export interface Tag extends ModelBase {
label: string;
}
const useTags = () => {
return useSelector(createTagsSelector());
const { queryKey, ...result } = useApiQuery<Tag[]>({
path: '/tag',
queryOptions: {
gcTime: Infinity,
},
});
return {
...result,
data: result.data ?? DEFAULT_TAGS,
};
};
export default useTags;
export const useTagList = () => {
const { data: tags } = useTags();
return tags;
};
export const useSortedTagList = () => {
const tagList = useTagList();
return useMemo(() => {
return tagList.sort(sortByProp('label'));
}, [tagList]);
};
export const useAddTag = (onTagCreated?: (tag: Tag) => void) => {
const queryClient = useQueryClient();
const [error, setError] = useState<ValidationFailures | null>(null);
const { mutate, isPending } = useApiMutation<Tag, Pick<Tag, 'label'>>({
path: '/tag',
method: 'POST',
mutationOptions: {
onMutate: () => {
setError(null);
},
onSuccess: (data) => {
queryClient.setQueryData<Tag[]>(['tag'], (oldData) => {
if (!oldData) {
return oldData;
}
return [...oldData, data];
});
onTagCreated?.(data);
},
onError: (error) => {
const validationFailures = getValidationFailures(error);
setError(validationFailures);
},
},
});
return {
addTag: mutate,
isAddingTag: isPending,
addTagError: error,
};
};
export const useDeleteTag = (id: number) => {
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const { mutate, isPending } = useApiMutation<Tag, void>({
path: `/tag/${id}`,
method: 'DELETE',
mutationOptions: {
onMutate: () => {
setError(null);
},
onSuccess: () => {
queryClient.setQueryData<Tag[]>(['tag'], (oldData) => {
if (!oldData) {
return oldData;
}
return oldData.filter((tag) => tag.id === id);
});
},
onError: () => {
setError('Error deleting tag');
},
},
});
return {
deleteTag: mutate,
isDeletingTag: isPending,
deleteTagError: error,
};
};