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

View file

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

View file

@ -1,25 +1,9 @@
import ModelBase from 'App/ModelBase';
import AppSectionState, { import AppSectionState, {
AppSectionDeleteState, AppSectionDeleteState,
AppSectionSaveState, AppSectionSaveState,
} from 'App/State/AppSectionState'; } from 'App/State/AppSectionState';
import { TagDetail } from 'Tags/useTagDetails';
export interface Tag extends ModelBase { import { Tag } from 'Tags/useTags';
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[];
}
export interface TagDetailAppState export interface TagDetailAppState
extends AppSectionState<TagDetail>, extends AppSectionState<TagDetail>,

View file

@ -1,6 +1,5 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useSelector } from 'react-redux'; import { useTagList } from 'Tags/useTags';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import FilterBuilderRowValue, { import FilterBuilderRowValue, {
FilterBuilderRowValueProps, FilterBuilderRowValueProps,
} from './FilterBuilderRowValue'; } from './FilterBuilderRowValue';
@ -11,7 +10,7 @@ type TagFilterBuilderRowValueProps<T> = Omit<
>; >;
function TagFilterBuilderRowValue<T>(props: TagFilterBuilderRowValueProps<T>) { function TagFilterBuilderRowValue<T>(props: TagFilterBuilderRowValueProps<T>) {
const tags = useSelector(createTagsSelector()); const tags = useTagList();
const tagList = useMemo(() => { const tagList = useMemo(() => {
return tags.map((tag) => { 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 Link from 'Components/Link/Link';
import { inputTypes } from 'Helpers/Props'; import { inputTypes } from 'Helpers/Props';
import { InputType } from 'Helpers/Props/inputTypes'; import { InputType } from 'Helpers/Props/inputTypes';
@ -9,6 +9,7 @@ import CaptchaInput, { CaptchaInputProps } from './CaptchaInput';
import CheckInput, { CheckInputProps } from './CheckInput'; import CheckInput, { CheckInputProps } from './CheckInput';
import FloatInput, { FloatInputProps } from './FloatInput'; import FloatInput, { FloatInputProps } from './FloatInput';
import { FormInputButtonProps } from './FormInputButton'; import { FormInputButtonProps } from './FormInputButton';
import { FormInputGroupProvider } from './FormInputGroupContext';
import FormInputHelpText from './FormInputHelpText'; import FormInputHelpText from './FormInputHelpText';
import KeyValueListInput, { KeyValueListInputProps } from './KeyValueListInput'; import KeyValueListInput, { KeyValueListInputProps } from './KeyValueListInput';
import NumberInput, { NumberInputProps } from './NumberInput'; import NumberInput, { NumberInputProps } from './NumberInput';
@ -206,11 +207,27 @@ function FormInputGroup<T, C extends InputType>(
helpTextWarning, helpTextWarning,
helpLink, helpLink,
pending, pending,
errors = [], errors: serverErrors = [],
warnings = [], warnings: serverWarnings = [],
...otherProps ...otherProps
} = props; } = 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 InputComponent = componentMap[type];
const checkInput = type === inputTypes.CHECK; const checkInput = type === inputTypes.CHECK;
const hasError = !!errors.length; const hasError = !!errors.length;
@ -220,44 +237,48 @@ function FormInputGroup<T, C extends InputType>(
const hasButton = !!buttonsArray.length; const hasButton = !!buttonsArray.length;
return ( return (
<div className={containerClassName}> <FormInputGroupProvider
<div className={className}> setClientErrors={setClientErrors}
<div className={styles.inputContainer}> setClientWarnings={setClientWarnings}
{/* @ts-expect-error - types are validated already */} >
<InputComponent <div className={containerClassName}>
className={inputClassName} <div className={className}>
helpText={helpText} <div className={styles.inputContainer}>
helpTextWarning={helpTextWarning} {/* @ts-expect-error - types are validated already */}
hasError={hasError} <InputComponent
hasWarning={hasWarning} className={inputClassName}
hasButton={hasButton} helpText={helpText}
{...otherProps} helpTextWarning={helpTextWarning}
/> hasError={hasError}
hasWarning={hasWarning}
hasButton={hasButton}
{...otherProps}
/>
{unit && ( {unit && (
<div <div
className={ className={
type === inputTypes.NUMBER type === inputTypes.NUMBER
? styles.inputUnitNumber ? styles.inputUnitNumber
: styles.inputUnit : styles.inputUnit
} }
> >
{unit} {unit}
</div> </div>
)} )}
</div> </div>
{buttonsArray.map((button, index) => { {buttonsArray.map((button, index) => {
if (!React.isValidElement<FormInputButtonProps>(button)) { if (!React.isValidElement<FormInputButtonProps>(button)) {
return button; return button;
} }
return React.cloneElement(button, { return React.cloneElement(button, {
isLastButton: index === lastButtonIndex, isLastButton: index === lastButtonIndex,
}); });
})} })}
{/* <div className={styles.pendingChangesContainer}> {/* <div className={styles.pendingChangesContainer}>
{ {
pending && pending &&
<Icon <Icon
@ -267,70 +288,71 @@ function FormInputGroup<T, C extends InputType>(
/> />
} }
</div> */} </div> */}
</div>
{!checkInput && helpText ? <FormInputHelpText text={helpText} /> : null}
{!checkInput && helpTexts ? (
<div>
{helpTexts.map((text, index) => {
return (
<FormInputHelpText
key={index}
text={text}
isCheckInput={checkInput}
/>
);
})}
</div> </div>
) : null}
{(!checkInput || helpText) && helpTextWarning ? ( {!checkInput && helpText ? <FormInputHelpText text={helpText} /> : null}
<FormInputHelpText text={helpTextWarning} isWarning={true} />
) : null}
{helpLink ? <Link to={helpLink}>{translate('MoreInfo')}</Link> : null} {!checkInput && helpTexts ? (
<div>
{helpTexts.map((text, index) => {
return (
<FormInputHelpText
key={index}
text={text}
isCheckInput={checkInput}
/>
);
})}
</div>
) : null}
{errors.map((error, index) => { {(!checkInput || helpText) && helpTextWarning ? (
return 'errorMessage' in error ? ( <FormInputHelpText text={helpTextWarning} isWarning={true} />
<FormInputHelpText ) : null}
key={index}
text={error.errorMessage}
link={error.infoLink}
tooltip={error.detailedDescription}
isError={true}
isCheckInput={checkInput}
/>
) : (
<FormInputHelpText
key={index}
text={error.message}
isError={true}
isCheckInput={checkInput}
/>
);
})}
{warnings.map((warning, index) => { {helpLink ? <Link to={helpLink}>{translate('MoreInfo')}</Link> : null}
return 'errorMessage' in warning ? (
<FormInputHelpText {errors.map((error, index) => {
key={index} return 'errorMessage' in error ? (
text={warning.errorMessage} <FormInputHelpText
link={warning.infoLink} key={index}
tooltip={warning.detailedDescription} text={error.errorMessage}
isWarning={true} link={error.infoLink}
isCheckInput={checkInput} tooltip={error.detailedDescription}
/> isError={true}
) : ( isCheckInput={checkInput}
<FormInputHelpText />
key={index} ) : (
text={warning.message} <FormInputHelpText
isWarning={true} key={index}
isCheckInput={checkInput} text={error.message}
/> isError={true}
); isCheckInput={checkInput}
})} />
</div> );
})}
{warnings.map((warning, index) => {
return 'errorMessage' in warning ? (
<FormInputHelpText
key={index}
text={warning.errorMessage}
link={warning.infoLink}
tooltip={warning.detailedDescription}
isWarning={true}
isCheckInput={checkInput}
/>
) : (
<FormInputHelpText
key={index}
text={warning.message}
isWarning={true}
isCheckInput={checkInput}
/>
);
})}
</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 React, { useCallback, useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { Tag, useAddTag, useSortedTagList } from 'Tags/useTags';
import { createSelector } from 'reselect';
import { addTag } from 'Store/Actions/tagActions';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import { InputChanged } from 'typings/inputs'; import { InputChanged } from 'typings/inputs';
import sortByProp from 'Utilities/Array/sortByProp'; import { useFormInputGroup } from '../FormInputGroupContext';
import TagInput, { TagBase, TagInputProps } from './TagInput'; import TagInput, { TagBase, TagInputProps } from './TagInput';
interface SeriesTag extends TagBase { interface SeriesTag extends TagBase {
@ -22,45 +19,33 @@ export interface SeriesTagInputProps<V>
onChange: (change: InputChanged<V>) => void; onChange: (change: InputChanged<V>) => void;
} }
const VALID_TAG_REGEX = new RegExp('[^-_a-z0-9]', 'i'); function useSeriesTags(tags: number[]) {
const sortedTags = useSortedTagList();
const filteredTagList = sortedTags.filter((tag) => !tags.includes(tag.id));
function isValidTag(tagName: string) { return {
try { tags: tags.reduce((acc: SeriesTag[], tag) => {
return !VALID_TAG_REGEX.test(tagName); const matchingTag = sortedTags.find((t) => t.id === tag);
} catch {
return false;
}
}
function createSeriesTagsSelector(tags: number[]) { if (matchingTag) {
return createSelector(createTagsSelector(), (tagList) => { acc.push({
const sortedTags = tagList.sort(sortByProp('label')); id: tag,
const filteredTagList = sortedTags.filter((tag) => !tags.includes(tag.id)); name: matchingTag.label,
});
}
return { return acc;
tags: tags.reduce((acc: SeriesTag[], tag) => { }, []),
const matchingTag = tagList.find((t) => t.id === tag);
if (matchingTag) { tagList: filteredTagList.map(({ id, label: name }) => {
acc.push({ return {
id: tag, id,
name: matchingTag.label, name,
}); };
} }),
return acc; allTags: sortedTags,
}, []), };
tagList: filteredTagList.map(({ id, label: name }) => {
return {
id,
name,
};
}),
allTags: sortedTags,
};
});
} }
export default function SeriesTagInput<V extends number | number[]>({ export default function SeriesTagInput<V extends number | number[]>({
@ -69,7 +54,7 @@ export default function SeriesTagInput<V extends number | number[]>({
onChange, onChange,
...otherProps ...otherProps
}: SeriesTagInputProps<V>) { }: SeriesTagInputProps<V>) {
const dispatch = useDispatch(); const formInputActions = useFormInputGroup();
const isArray = Array.isArray(value); const isArray = Array.isArray(value);
const arrayValue = useMemo(() => { const arrayValue = useMemo(() => {
@ -80,12 +65,10 @@ export default function SeriesTagInput<V extends number | number[]>({
return value === 0 ? [] : [value as number]; return value === 0 ? [] : [value as number];
}, [isArray, value]); }, [isArray, value]);
const { tags, tagList, allTags } = useSelector( const { tags, tagList, allTags } = useSeriesTags(arrayValue);
createSeriesTagsSelector(arrayValue)
);
const handleTagCreated = useCallback( const handleTagCreated = useCallback(
(tag: SeriesTag) => { (tag: Tag) => {
if (isArray) { if (isArray) {
onChange({ name, value: [...value, tag.id] as V }); onChange({ name, value: [...value, tag.id] as V });
} else { } else {
@ -98,6 +81,8 @@ export default function SeriesTagInput<V extends number | number[]>({
[name, value, isArray, onChange] [name, value, isArray, onChange]
); );
const { addTag, addTagError } = useAddTag(handleTagCreated);
const handleTagAdd = useCallback( const handleTagAdd = useCallback(
(newTag: SeriesTag) => { (newTag: SeriesTag) => {
if (newTag.id) { if (newTag.id) {
@ -112,16 +97,13 @@ export default function SeriesTagInput<V extends number | number[]>({
const existingTag = allTags.some((t) => t.label === newTag.name); const existingTag = allTags.some((t) => t.label === newTag.name);
if (isValidTag(newTag.name) && !existingTag) { if (!existingTag) {
dispatch( addTag({
addTag({ label: newTag.name,
tag: { label: newTag.name }, });
onTagCreated: handleTagCreated,
})
);
} }
}, },
[name, value, isArray, allTags, handleTagCreated, onChange, dispatch] [name, value, isArray, allTags, onChange, addTag]
); );
const handleTagDelete = useCallback( const handleTagDelete = useCallback(
@ -138,6 +120,15 @@ export default function SeriesTagInput<V extends number | number[]>({
[name, value, isArray, onChange] [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 ( return (
<TagInput <TagInput
{...otherProps} {...otherProps}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,14 +1,22 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'; import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Error } from 'App/State/AppSectionState'; import { ValidationFailures } from 'Store/Selectors/selectSettings';
import fetchJson, { FetchJsonOptions } from 'Utilities/Fetch/fetchJson'; import {
ValidationError,
ValidationFailure,
ValidationWarning,
} from 'typings/pending';
import fetchJson, {
ApiError,
FetchJsonOptions,
} from 'Utilities/Fetch/fetchJson';
import getQueryPath from 'Utilities/Fetch/getQueryPath'; import getQueryPath from 'Utilities/Fetch/getQueryPath';
import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString'; import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString';
interface MutationOptions<T, TData> interface MutationOptions<T, TData>
extends Omit<FetchJsonOptions<TData>, 'method'> { extends Omit<FetchJsonOptions<TData>, 'method'> {
method: 'POST' | 'PUT' | 'DELETE'; method: 'POST' | 'PUT' | 'DELETE';
mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>; mutationOptions?: Omit<UseMutationOptions<T, ApiError, TData>, 'mutationFn'>;
queryParams?: QueryParams; queryParams?: QueryParams;
} }
@ -25,7 +33,7 @@ function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
}; };
}, [options]); }, [options]);
return useMutation<T, Error, TData>({ return useMutation<T, ApiError, TData>({
...options.mutationOptions, ...options.mutationOptions,
mutationFn: async (data?: TData) => { mutationFn: async (data?: TData) => {
const { path, ...otherOptions } = requestOptions; const { path, ...otherOptions } = requestOptions;
@ -36,3 +44,30 @@ function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
} }
export default useApiMutation; 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, fetchQualityProfiles,
fetchUISettings, fetchUISettings,
} from 'Store/Actions/settingsActions'; } from 'Store/Actions/settingsActions';
import { fetchTags } from 'Store/Actions/tagActions';
import useSystemStatus from 'System/Status/useSystemStatus'; import useSystemStatus from 'System/Status/useSystemStatus';
import useTags from 'Tags/useTags';
import { ApiError } from 'Utilities/Fetch/fetchJson'; import { ApiError } from 'Utilities/Fetch/fetchJson';
const createErrorsSelector = ({ const createErrorsSelector = ({
systemStatusError, systemStatusError,
tagsError,
}: { }: {
systemStatusError: ApiError | null; systemStatusError: ApiError | null;
tagsError: ApiError | null;
}) => }) =>
createSelector( createSelector(
(state: AppState) => state.series.error, (state: AppState) => state.series.error,
(state: AppState) => state.customFilters.error, (state: AppState) => state.customFilters.error,
(state: AppState) => state.tags.error,
(state: AppState) => state.settings.ui.error, (state: AppState) => state.settings.ui.error,
(state: AppState) => state.settings.qualityProfiles.error, (state: AppState) => state.settings.qualityProfiles.error,
(state: AppState) => state.settings.languages.error, (state: AppState) => state.settings.languages.error,
@ -34,7 +35,6 @@ const createErrorsSelector = ({
( (
seriesError, seriesError,
customFiltersError, customFiltersError,
tagsError,
uiSettingsError, uiSettingsError,
qualityProfilesError, qualityProfilesError,
languagesError, languagesError,
@ -45,13 +45,13 @@ const createErrorsSelector = ({
const hasError = !!( const hasError = !!(
seriesError || seriesError ||
customFiltersError || customFiltersError ||
tagsError ||
uiSettingsError || uiSettingsError ||
qualityProfilesError || qualityProfilesError ||
languagesError || languagesError ||
importListsError || importListsError ||
indexerFlagsError || indexerFlagsError ||
systemStatusError || systemStatusError ||
tagsError ||
translationsError translationsError
); );
@ -78,22 +78,25 @@ const useAppPage = () => {
const { isFetched: isSystemStatusFetched, error: systemStatusError } = const { isFetched: isSystemStatusFetched, error: systemStatusError } =
useSystemStatus(); useSystemStatus();
const { isFetched: isTagsFetched, error: tagsError } = useTags();
const isAppStatePopulated = useSelector(
(state: AppState) =>
state.series.isPopulated &&
state.customFilters.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
);
const isPopulated = const isPopulated =
useSelector( isAppStatePopulated && isSystemStatusFetched && isTagsFetched;
(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 { hasError, errors } = useSelector( const { hasError, errors } = useSelector(
createErrorsSelector({ systemStatusError }) createErrorsSelector({ systemStatusError, tagsError })
); );
const isLocalStorageSupported = useMemo(() => { const isLocalStorageSupported = useMemo(() => {
@ -112,7 +115,6 @@ const useAppPage = () => {
useEffect(() => { useEffect(() => {
dispatch(fetchSeries()); dispatch(fetchSeries());
dispatch(fetchCustomFilters()); dispatch(fetchCustomFilters());
dispatch(fetchTags());
dispatch(fetchQualityProfiles()); dispatch(fetchQualityProfiles());
dispatch(fetchLanguages()); dispatch(fetchLanguages());
dispatch(fetchImportLists()); dispatch(fetchImportLists());

View file

@ -2,7 +2,7 @@ import React from 'react';
import Label from 'Components/Label'; import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props'; import { kinds, sizes } from 'Helpers/Props';
import useSeries from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import useTags from 'Tags/useTags'; import { useTagList } from 'Tags/useTags';
import sortByProp from 'Utilities/Array/sortByProp'; import sortByProp from 'Utilities/Array/sortByProp';
interface SeriesTagsProps { interface SeriesTagsProps {
@ -11,7 +11,7 @@ interface SeriesTagsProps {
function SeriesTags({ seriesId }: SeriesTagsProps) { function SeriesTags({ seriesId }: SeriesTagsProps) {
const series = useSeries(seriesId)!; const series = useSeries(seriesId)!;
const tagList = useTags(); const tagList = useTagList();
const tags = series.tags const tags = series.tags
.map((tagId) => tagList.find((tag) => tag.id === tagId)) .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 React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useSelect } from 'App/Select/SelectContext'; import { useSelect } from 'App/Select/SelectContext';
import { Tag } from 'App/State/TagsAppState';
import Form from 'Components/Form/Form'; import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup'; import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup'; import FormInputGroup from 'Components/Form/FormInputGroup';
@ -17,7 +16,7 @@ import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props'; import { inputTypes, kinds, sizes } from 'Helpers/Props';
import Series from 'Series/Series'; import Series from 'Series/Series';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; 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 translate from 'Utilities/String/translate';
import styles from './TagsModalContent.css'; import styles from './TagsModalContent.css';
@ -31,7 +30,7 @@ function TagsModalContent({
onApplyTagsPress, onApplyTagsPress,
}: TagsModalContentProps) { }: TagsModalContentProps) {
const allSeries: Series[] = useSelector(createAllSeriesSelector()); const allSeries: Series[] = useSelector(createAllSeriesSelector());
const tagList: Tag[] = useSelector(createTagsSelector()); const tagList: Tag[] = useTagList();
const [tags, setTags] = useState<number[]>([]); const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add'); const [applyTags, setApplyTags] = useState('add');

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,5 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { Tag } from 'App/State/TagsAppState';
import Card from 'Components/Card'; import Card from 'Components/Card';
import Label from 'Components/Label'; import Label from 'Components/Label';
import MiddleTruncate from 'Components/MiddleTruncate'; import MiddleTruncate from 'Components/MiddleTruncate';
@ -9,6 +8,7 @@ import TagList from 'Components/TagList';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import { deleteReleaseProfile } from 'Store/Actions/Settings/releaseProfiles'; import { deleteReleaseProfile } from 'Store/Actions/Settings/releaseProfiles';
import { Tag } from 'Tags/useTags';
import Indexer from 'typings/Indexer'; import Indexer from 'typings/Indexer';
import ReleaseProfile from 'typings/Settings/ReleaseProfile'; import ReleaseProfile from 'typings/Settings/ReleaseProfile';
import translate from 'Utilities/String/translate'; 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 { fetchIndexers } from 'Store/Actions/Settings/indexers';
import { fetchReleaseProfiles } from 'Store/Actions/Settings/releaseProfiles'; import { fetchReleaseProfiles } from 'Store/Actions/Settings/releaseProfiles';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector'; import { useTagList } from 'Tags/useTags';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import EditReleaseProfileModal from './EditReleaseProfileModal'; import EditReleaseProfileModal from './EditReleaseProfileModal';
import ReleaseProfileItem from './ReleaseProfileItem'; import ReleaseProfileItem from './ReleaseProfileItem';
@ -21,7 +21,7 @@ function ReleaseProfiles() {
const { items, isFetching, isPopulated, error }: ReleaseProfilesAppState = const { items, isFetching, isPopulated, error }: ReleaseProfilesAppState =
useSelector(createClientSideCollectionSelector('settings.releaseProfiles')); useSelector(createClientSideCollectionSelector('settings.releaseProfiles'));
const tagList = useSelector(createTagsSelector()); const tagList = useTagList();
const indexerList = useSelector( const indexerList = useSelector(
(state: AppState) => state.settings.indexers.items (state: AppState) => state.settings.indexers.items
); );

View file

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

View file

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

View file

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

View file

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

View file

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

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'; } from 'typings/pending';
import isEmpty from 'Utilities/Object/isEmpty'; import isEmpty from 'Utilities/Object/isEmpty';
interface ValidationFailures { export interface ValidationFailures {
errors: ValidationError[]; errors: ValidationError[];
warnings: ValidationWarning[]; warnings: ValidationWarning[];
} }
function getValidationFailures(saveError?: Error | null): ValidationFailures { export function getValidationFailures(
saveError?: Error | null
): ValidationFailures {
if (!saveError || saveError.status !== 400) { if (!saveError || saveError.status !== 400) {
return { return {
errors: [], 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 { useQueryClient } from '@tanstack/react-query';
import createTagsSelector from 'Store/Selectors/createTagsSelector'; 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 = () => { 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 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,
};
};