Convert Tags to TypeScript

pull/7605/head
Mark McDowall 2 months ago
parent 405ee7473c
commit 89f584d1b3
No known key found for this signature in database

@ -43,9 +43,8 @@ export interface AppSectionSchemaState<T> {
isSchemaFetching: boolean;
isSchemaPopulated: boolean;
schemaError: Error;
schema: {
items: T[];
};
schema: T[];
selectedSchema?: T;
}
export interface AppSectionItemSchemaState<T> {
@ -68,9 +67,10 @@ export interface AppSectionProviderState<T>
AppSectionSaveState {
isFetching: boolean;
isPopulated: boolean;
isTesting?: boolean;
error: Error;
items: T[];
pendingChanges: Partial<T>;
pendingChanges?: Partial<T>;
}
interface AppSectionState<T> {

@ -3,10 +3,13 @@ import AppSectionState, {
AppSectionItemSchemaState,
AppSectionItemState,
AppSectionSaveState,
AppSectionSchemaState,
PagedAppSectionState,
} from 'App/State/AppSectionState';
import Language from 'Language/Language';
import AutoTagging, { AutoTaggingSpecification } from 'typings/AutoTagging';
import CustomFormat from 'typings/CustomFormat';
import DelayProfile from 'typings/DelayProfile';
import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList';
import ImportListExclusion from 'typings/ImportListExclusion';
@ -22,6 +25,22 @@ import ReleaseProfile from 'typings/Settings/ReleaseProfile';
import UiSettings from 'typings/Settings/UiSettings';
import MetadataAppState from './MetadataAppState';
export interface AutoTaggingAppState
extends AppSectionState<AutoTagging>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface AutoTaggingSpecificationAppState
extends AppSectionState<AutoTaggingSpecification>,
AppSectionDeleteState,
AppSectionSaveState,
AppSectionSchemaState<AutoTaggingSpecification> {}
export interface DelayProfileAppState
extends AppSectionState<DelayProfile>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface DownloadClientAppState
extends AppSectionState<DownloadClient>,
AppSectionDeleteState,
@ -88,7 +107,10 @@ export type UiSettingsAppState = AppSectionItemState<UiSettings>;
interface SettingsAppState {
advancedSettings: boolean;
autoTaggings: AutoTaggingAppState;
autoTaggingSpecifications: AutoTaggingSpecificationAppState;
customFormats: CustomFormatAppState;
delayProfiles: DelayProfileAppState;
downloadClients: DownloadClientAppState;
general: GeneralAppState;
importListExclusions: ImportListExclusionsSettingsAppState;

@ -20,6 +20,7 @@ interface ProviderFieldFormGroupProps<T> {
hidden?: string;
isDisabled?: boolean;
provider?: string;
providerData?: object;
pending: boolean;
errors: Failure[];
warnings: Failure[];

@ -18,7 +18,7 @@ function createQualityProfilesSelector(
includeMixed: boolean
) {
return createSelector(
createSortedSectionSelector(
createSortedSectionSelector<QualityProfile, QualityProfilesAppState>(
'settings.qualityProfiles',
sortByProp<QualityProfile, 'name'>('name')
),

@ -1,4 +1,5 @@
import React from 'react';
import { Error } from 'App/State/AppSectionState';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { kinds } from 'Helpers/Props';
@ -6,7 +7,7 @@ import { kinds } from 'Helpers/Props';
interface PageSectionContentProps {
isFetching: boolean;
isPopulated: boolean;
error?: object;
error?: Error;
errorMessage: string;
children: React.ReactNode;
}
@ -18,7 +19,7 @@ function PageSectionContent({
errorMessage,
children,
}: PageSectionContentProps) {
if (isFetching) {
if (isFetching && !isPopulated) {
return <LoadingIndicator />;
}

@ -14,7 +14,7 @@ import styles from './Metadatas.css';
function createMetadatasSelector() {
return createSelector(
createSortedSectionSelector<MetadataType>(
createSortedSectionSelector<MetadataType, MetadataAppState>(
'settings.metadata',
sortByProp('name')
),

@ -1,27 +1,38 @@
import PropTypes from 'prop-types';
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';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import { icons, kinds } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { AutoTaggingSpecification } from 'typings/AutoTagging';
import translate from 'Utilities/String/translate';
import EditAutoTaggingModal from './EditAutoTaggingModal';
import styles from './AutoTagging.css';
export default function AutoTagging(props) {
const {
id,
name,
tags,
tagList,
specifications,
isDeleting,
onConfirmDeleteAutoTagging,
onCloneAutoTaggingPress
} = props;
interface AutoTaggingProps {
id: number;
name: string;
specifications: AutoTaggingSpecification[];
tags: number[];
tagList: Tag[];
isDeleting: boolean;
onConfirmDeleteAutoTagging: (id: number) => void;
onCloneAutoTaggingPress: (id: number) => void;
}
export default function AutoTagging({
id,
name,
tags,
tagList,
specifications,
isDeleting,
onConfirmDeleteAutoTagging,
onCloneAutoTaggingPress,
}: AutoTaggingProps) {
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@ -57,9 +68,7 @@ export default function AutoTagging(props) {
onPress={onEditPress}
>
<div className={styles.nameContainer}>
<div className={styles.name}>
{name}
</div>
<div className={styles.name}>{name}</div>
<div>
<IconButton
@ -71,36 +80,29 @@ export default function AutoTagging(props) {
</div>
</div>
<TagList
tags={tags}
tagList={tagList}
/>
<TagList tags={tags} tagList={tagList} />
<div>
{
specifications.map((item, index) => {
if (!item) {
return null;
}
let kind = kinds.DEFAULT;
if (item.required) {
kind = kinds.SUCCESS;
}
if (item.negate) {
kind = kinds.DANGER;
}
return (
<Label
key={index}
kind={kind}
>
{item.name}
</Label>
);
})
}
{specifications.map((item, index) => {
if (!item) {
return null;
}
let kind: Kind = 'default';
if (item.required) {
kind = 'success';
}
if (item.negate) {
kind = 'danger';
}
return (
<Label key={index} kind={kind}>
{item.name}
</Label>
);
})}
</div>
<EditAutoTaggingModal
@ -123,14 +125,3 @@ export default function AutoTagging(props) {
</Card>
);
}
AutoTagging.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
specifications: PropTypes.arrayOf(PropTypes.object).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
isDeleting: PropTypes.bool.isRequired,
onConfirmDeleteAutoTagging: PropTypes.func.isRequired,
onCloneAutoTaggingPress: PropTypes.func.isRequired
};

@ -1,14 +1,20 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { AutoTaggingAppState } from 'App/State/SettingsAppState';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { icons } from 'Helpers/Props';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { cloneAutoTagging, deleteAutoTagging, fetchAutoTaggings } from 'Store/Actions/settingsActions';
import {
cloneAutoTagging,
deleteAutoTagging,
fetchAutoTaggings,
} from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import AutoTaggingModel from 'typings/AutoTagging';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import AutoTagging from './AutoTagging';
@ -16,27 +22,27 @@ import EditAutoTaggingModal from './EditAutoTaggingModal';
import styles from './AutoTaggings.css';
export default function AutoTaggings() {
const {
error,
items,
isDeleting,
isFetching,
isPopulated
} = useSelector(
createSortedSectionSelector('settings.autoTaggings', sortByProp('name'))
const { error, items, isDeleting, isFetching, isPopulated } = useSelector(
createSortedSectionSelector<AutoTaggingModel, AutoTaggingAppState>(
'settings.autoTaggings',
sortByProp('name')
)
);
const tagList = useSelector(createTagsSelector());
const dispatch = useDispatch();
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [tagsFromId, setTagsFromId] = useState(undefined);
const [tagsFromId, setTagsFromId] = useState<number>();
const onClonePress = useCallback((id) => {
dispatch(cloneAutoTagging({ id }));
const onClonePress = useCallback(
(id: number) => {
dispatch(cloneAutoTagging({ id }));
setTagsFromId(id);
setIsEditModalOpen(true);
}, [dispatch, setIsEditModalOpen]);
setTagsFromId(id);
setIsEditModalOpen(true);
},
[dispatch, setIsEditModalOpen]
);
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
@ -46,9 +52,12 @@ export default function AutoTaggings() {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onConfirmDelete = useCallback((id) => {
dispatch(deleteAutoTagging({ id }));
}, [dispatch]);
const onConfirmDelete = useCallback(
(id: number) => {
dispatch(deleteAutoTagging({ id }));
},
[dispatch]
);
useEffect(() => {
dispatch(fetchAutoTaggings());
@ -64,30 +73,22 @@ export default function AutoTaggings() {
isPopulated={isPopulated}
>
<div className={styles.autoTaggings}>
{
items.map((item) => {
return (
<AutoTagging
key={item.id}
{...item}
isDeleting={isDeleting}
tagList={tagList}
onConfirmDeleteAutoTagging={onConfirmDelete}
onCloneAutoTaggingPress={onClonePress}
/>
);
})
}
{items.map((item) => {
return (
<AutoTagging
key={item.id}
{...item}
isDeleting={isDeleting}
tagList={tagList}
onConfirmDeleteAutoTagging={onConfirmDelete}
onCloneAutoTaggingPress={onClonePress}
/>
);
})}
<Card
className={styles.addAutoTagging}
onPress={onEditPress}
>
<Card className={styles.addAutoTagging} onPress={onEditPress}>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={45}
/>
<Icon name={icons.ADD} size={45} />
</div>
</Card>
</div>
@ -97,7 +98,6 @@ export default function AutoTaggings() {
tagsFromId={tagsFromId}
onModalClose={onEditModalClose}
/>
</PageSectionContent>
</FieldSet>
);

@ -1,50 +0,0 @@
import PropTypes from 'prop-types';
import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditAutoTaggingModalContent from './EditAutoTaggingModalContent';
export default function EditAutoTaggingModal(props) {
const {
isOpen,
onModalClose: onOriginalModalClose,
...otherProps
} = props;
const dispatch = useDispatch();
const [height, setHeight] = useState('auto');
const onContentHeightChange = useCallback((h) => {
if (height === 'auto' || h > height) {
setHeight(h);
}
}, [height, setHeight]);
const onModalClose = useCallback(() => {
dispatch(clearPendingChanges({ section: 'settings.autoTaggings' }));
onOriginalModalClose();
}, [dispatch, onOriginalModalClose]);
return (
<Modal
style={{ height: height === 'auto' ? 'auto': `${height}px` }}
isOpen={isOpen}
size={sizes.LARGE}
onModalClose={onModalClose}
>
<EditAutoTaggingModalContent
{...otherProps}
onContentHeightChange={onContentHeightChange}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditAutoTaggingModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};

@ -0,0 +1,35 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditAutoTaggingModalContent, {
EditAutoTaggingModalContentProps,
} from './EditAutoTaggingModalContent';
interface EditAutoTaggingModalProps extends EditAutoTaggingModalContentProps {
isOpen: boolean;
onModalClose: () => void;
}
export default function EditAutoTaggingModal({
isOpen,
onModalClose,
...otherProps
}: EditAutoTaggingModalProps) {
const dispatch = useDispatch();
const handleModalClose = useCallback(() => {
dispatch(clearPendingChanges({ section: 'settings.autoTaggings' }));
onModalClose();
}, [dispatch, onModalClose]);
return (
<Modal isOpen={isOpen} size={sizes.LARGE} onModalClose={handleModalClose}>
<EditAutoTaggingModalContent
{...otherProps}
onModalClose={handleModalClose}
/>
</Modal>
);
}

@ -1,270 +0,0 @@
import PropTypes from 'prop-types';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { icons, inputTypes, kinds } from 'Helpers/Props';
import {
cloneAutoTaggingSpecification,
deleteAutoTaggingSpecification,
fetchAutoTaggingSpecifications,
saveAutoTagging,
setAutoTaggingValue
} from 'Store/Actions/settingsActions';
import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector';
import translate from 'Utilities/String/translate';
import AddSpecificationModal from './Specifications/AddSpecificationModal';
import EditSpecificationModal from './Specifications/EditSpecificationModal';
import Specification from './Specifications/Specification';
import styles from './EditAutoTaggingModalContent.css';
export default function EditAutoTaggingModalContent(props) {
const {
id,
tagsFromId,
onModalClose,
onDeleteAutoTaggingPress
} = props;
const {
error,
item,
isFetching,
isSaving,
saveError,
validationErrors,
validationWarnings
} = useSelector(createProviderSettingsSelectorHook('autoTaggings', id));
const {
isPopulated: specificationsPopulated,
items: specifications
} = useSelector((state) => state.settings.autoTaggingSpecifications);
const dispatch = useDispatch();
const [isAddSpecificationModalOpen, setIsAddSpecificationModalOpen] = useState(false);
const [isEditSpecificationModalOpen, setIsEditSpecificationModalOpen] = useState(false);
// const [isImportAutoTaggingModalOpen, setIsImportAutoTaggingModalOpen] = useState(false);
const onAddSpecificationPress = useCallback(() => {
setIsAddSpecificationModalOpen(true);
}, [setIsAddSpecificationModalOpen]);
const onAddSpecificationModalClose = useCallback(({ specificationSelected = false } = {}) => {
setIsAddSpecificationModalOpen(false);
setIsEditSpecificationModalOpen(specificationSelected);
}, [setIsAddSpecificationModalOpen]);
const onEditSpecificationModalClose = useCallback(() => {
setIsEditSpecificationModalOpen(false);
}, [setIsEditSpecificationModalOpen]);
const onInputChange = useCallback(({ name, value }) => {
dispatch(setAutoTaggingValue({ name, value }));
}, [dispatch]);
const onSavePress = useCallback(() => {
dispatch(saveAutoTagging({ id }));
}, [dispatch, id]);
const onCloneSpecificationPress = useCallback((specId) => {
dispatch(cloneAutoTaggingSpecification({ id: specId }));
}, [dispatch]);
const onConfirmDeleteSpecification = useCallback((specId) => {
dispatch(deleteAutoTaggingSpecification({ id: specId }));
}, [dispatch]);
useEffect(() => {
dispatch(fetchAutoTaggingSpecifications({ id: tagsFromId || id }));
}, [id, tagsFromId, dispatch]);
const isSavingRef = useRef();
useEffect(() => {
if (isSavingRef.current && !isSaving && !saveError) {
onModalClose();
}
isSavingRef.current = isSaving;
}, [isSaving, saveError, onModalClose]);
const {
name,
removeTagsAutomatically,
tags
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? translate('EditAutoTag') : translate('AddAutoTag')}
</ModalHeader>
<ModalBody>
<div>
{
isFetching ? <LoadingIndicator />: null
}
{
!isFetching && !!error ?
<Alert kind={kinds.DANGER}>
{translate('AddAutoTagError')}
</Alert> :
null
}
{
!isFetching && !error && specificationsPopulated ?
<div>
<Form
validationErrors={validationErrors}
validationWarnings={validationWarnings}
>
<FormGroup>
<FormLabel>
{translate('Name')}
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RemoveTagsAutomatically')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="removeTagsAutomatically"
helpText={translate('RemoveTagsAutomaticallyHelpText')}
{...removeTagsAutomatically}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={onInputChange}
{...tags}
/>
</FormGroup>
</Form>
<FieldSet legend={translate('Conditions')}>
<div className={styles.autoTaggings}>
{
specifications.map((tag) => {
return (
<Specification
key={tag.id}
{...tag}
onCloneSpecificationPress={onCloneSpecificationPress}
onConfirmDeleteSpecification={onConfirmDeleteSpecification}
/>
);
})
}
<Card
className={styles.addSpecification}
onPress={onAddSpecificationPress}
>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={45}
/>
</div>
</Card>
</div>
</FieldSet>
<AddSpecificationModal
isOpen={isAddSpecificationModalOpen}
onModalClose={onAddSpecificationModalClose}
/>
<EditSpecificationModal
isOpen={isEditSpecificationModalOpen}
onModalClose={onEditSpecificationModalClose}
/>
{/* <ImportAutoTaggingModal
isOpen={isImportAutoTaggingModalOpen}
onModalClose={onImportAutoTaggingModalClose}
/> */}
</div> :
null
}
</div>
</ModalBody>
<ModalFooter>
<div className={styles.rightButtons}>
{
id ?
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteAutoTaggingPress}
>
{translate('Delete')}
</Button> :
null
}
{/* <Button
className={styles.deleteButton}
onPress={onImportPress}
>
Import
</Button> */}
</div>
<Button
onPress={onModalClose}
>
{translate('Cancel')}
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
EditAutoTaggingModalContent.propTypes = {
id: PropTypes.number,
tagsFromId: PropTypes.number,
onModalClose: PropTypes.func.isRequired,
onDeleteAutoTaggingPress: PropTypes.func
};

@ -0,0 +1,258 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { icons, inputTypes, kinds } from 'Helpers/Props';
import {
cloneAutoTaggingSpecification,
deleteAutoTaggingSpecification,
fetchAutoTaggingSpecifications,
saveAutoTagging,
setAutoTaggingValue,
} from 'Store/Actions/settingsActions';
import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import AddSpecificationModal from './Specifications/AddSpecificationModal';
import EditSpecificationModal from './Specifications/EditSpecificationModal';
import Specification from './Specifications/Specification';
import styles from './EditAutoTaggingModalContent.css';
export interface EditAutoTaggingModalContentProps {
id?: number;
tagsFromId?: number;
onModalClose: () => void;
onDeleteAutoTaggingPress?: () => void;
}
export default function EditAutoTaggingModalContent({
id,
tagsFromId,
onModalClose,
onDeleteAutoTaggingPress,
}: EditAutoTaggingModalContentProps) {
const {
error,
item,
isFetching,
isSaving,
saveError,
validationErrors,
validationWarnings,
} = useSelector(createProviderSettingsSelectorHook('autoTaggings', id));
const { isPopulated: specificationsPopulated, items: specifications } =
useSelector((state: AppState) => state.settings.autoTaggingSpecifications);
const dispatch = useDispatch();
const [isAddSpecificationModalOpen, setIsAddSpecificationModalOpen] =
useState(false);
const [isEditSpecificationModalOpen, setIsEditSpecificationModalOpen] =
useState(false);
const handleAddSpecificationPress = useCallback(() => {
setIsAddSpecificationModalOpen(true);
}, [setIsAddSpecificationModalOpen]);
const handleAddSpecificationModalClose = useCallback(
({ specificationSelected = false } = {}) => {
setIsAddSpecificationModalOpen(false);
setIsEditSpecificationModalOpen(specificationSelected);
},
[setIsAddSpecificationModalOpen]
);
const handleEditSpecificationModalClose = useCallback(() => {
setIsEditSpecificationModalOpen(false);
}, [setIsEditSpecificationModalOpen]);
const handleInputChange = useCallback(
({ name, value }: InputChanged) => {
// @ts-expect-error - actions are not typed
dispatch(setAutoTaggingValue({ name, value }));
},
[dispatch]
);
const handleSavePress = useCallback(() => {
dispatch(saveAutoTagging({ id }));
}, [dispatch, id]);
const handleCloneSpecificationPress = useCallback(
(specId: number) => {
dispatch(cloneAutoTaggingSpecification({ id: specId }));
},
[dispatch]
);
const handleConfirmDeleteSpecification = useCallback(
(specId: number) => {
dispatch(deleteAutoTaggingSpecification({ id: specId }));
},
[dispatch]
);
useEffect(() => {
dispatch(fetchAutoTaggingSpecifications({ id: tagsFromId || id }));
}, [id, tagsFromId, dispatch]);
const isSavingRef = useRef(false);
useEffect(() => {
if (isSavingRef.current && !isSaving && !saveError) {
onModalClose();
}
isSavingRef.current = isSaving;
}, [isSaving, saveError, onModalClose]);
const { name, removeTagsAutomatically, tags } = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? translate('EditAutoTag') : translate('AddAutoTag')}
</ModalHeader>
<ModalBody>
<div>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>{translate('AddAutoTagError')}</Alert>
) : null}
{!isFetching && !error && specificationsPopulated ? (
<div>
<Form
validationErrors={validationErrors}
validationWarnings={validationWarnings}
>
<FormGroup>
<FormLabel>{translate('Name')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RemoveTagsAutomatically')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="removeTagsAutomatically"
helpText={translate('RemoveTagsAutomaticallyHelpText')}
{...removeTagsAutomatically}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={handleInputChange}
{...tags}
/>
</FormGroup>
</Form>
<FieldSet legend={translate('Conditions')}>
<div className={styles.autoTaggings}>
{specifications.map((specification) => {
return (
<Specification
key={specification.id}
{...specification}
onCloneSpecificationPress={
handleCloneSpecificationPress
}
onConfirmDeleteSpecification={
handleConfirmDeleteSpecification
}
/>
);
})}
<Card
className={styles.addSpecification}
onPress={handleAddSpecificationPress}
>
<div className={styles.center}>
<Icon name={icons.ADD} size={45} />
</div>
</Card>
</div>
</FieldSet>
<AddSpecificationModal
isOpen={isAddSpecificationModalOpen}
onModalClose={handleAddSpecificationModalClose}
/>
<EditSpecificationModal
isOpen={isEditSpecificationModalOpen}
onModalClose={handleEditSpecificationModalClose}
/>
{/* <ImportAutoTaggingModal
isOpen={isImportAutoTaggingModalOpen}
onModalClose={onImportAutoTaggingModalClose}
/> */}
</div>
) : null}
</div>
</ModalBody>
<ModalFooter>
<div className={styles.rightButtons}>
{id ? (
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteAutoTaggingPress}
>
{translate('Delete')}
</Button>
) : null}
{/* <Button
className={styles.deleteButton}
onPress={onImportPress}
>
Import
</Button> */}
</div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={handleSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}

@ -1,101 +0,0 @@
import PropTypes from 'prop-types';
import React, { useCallback } from 'react';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import Menu from 'Components/Menu/Menu';
import MenuContent from 'Components/Menu/MenuContent';
import { sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import AddSpecificationPresetMenuItem from './AddSpecificationPresetMenuItem';
import styles from './AddSpecificationItem.css';
export default function AddSpecificationItem(props) {
const {
implementation,
implementationName,
infoLink,
presets,
onSpecificationSelect
} = props;
const onWrappedSpecificationSelect = useCallback(() => {
onSpecificationSelect({ implementation });
}, [implementation, onSpecificationSelect]);
const hasPresets = !!presets && !!presets.length;
return (
<div
className={styles.specification}
>
<Link
className={styles.underlay}
onPress={onWrappedSpecificationSelect}
/>
<div className={styles.overlay}>
<div className={styles.name}>
{implementationName}
</div>
<div className={styles.actions}>
{
hasPresets ?
<span>
<Button
size={sizes.SMALL}
onPress={onWrappedSpecificationSelect}
>
{translate('Custom')}
</Button>
<Menu className={styles.presetsMenu}>
<Button
className={styles.presetsMenuButton}
size={sizes.SMALL}
>
{translate('Presets')}
</Button>
<MenuContent>
{
presets.map((preset, index) => {
return (
<AddSpecificationPresetMenuItem
key={index}
name={preset.name}
implementation={implementation}
onPress={onWrappedSpecificationSelect}
/>
);
})
}
</MenuContent>
</Menu>
</span> :
null
}
{
infoLink ?
<Button
to={infoLink}
size={sizes.SMALL}
>
{translate('MoreInfo')}
</Button> :
null
}
</div>
</div>
</div>
);
}
AddSpecificationItem.propTypes = {
implementation: PropTypes.string.isRequired,
implementationName: PropTypes.string.isRequired,
infoLink: PropTypes.string,
presets: PropTypes.arrayOf(PropTypes.object),
onSpecificationSelect: PropTypes.func.isRequired
};

@ -0,0 +1,81 @@
import React, { useCallback } from 'react';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import Menu from 'Components/Menu/Menu';
import MenuContent from 'Components/Menu/MenuContent';
import { sizes } from 'Helpers/Props';
import { AutoTaggingSpecification } from 'typings/AutoTagging';
import translate from 'Utilities/String/translate';
import AddSpecificationPresetMenuItem from './AddSpecificationPresetMenuItem';
import styles from './AddSpecificationItem.css';
interface AddSpecificationItemProps {
implementation: string;
implementationName: string;
infoLink?: string;
presets?: AutoTaggingSpecification[];
onSpecificationSelect: ({
implementation,
}: {
implementation: string;
}) => void;
}
export default function AddSpecificationItem({
implementation,
implementationName,
infoLink,
presets,
onSpecificationSelect,
}: AddSpecificationItemProps) {
const handleSpecificationSelect = useCallback(() => {
onSpecificationSelect({ implementation });
}, [implementation, onSpecificationSelect]);
const hasPresets = !!presets && !!presets.length;
return (
<div className={styles.specification}>
<Link className={styles.underlay} onPress={handleSpecificationSelect} />
<div className={styles.overlay}>
<div className={styles.name}>{implementationName}</div>
<div className={styles.actions}>
{hasPresets ? (
<span>
<Button size={sizes.SMALL} onPress={handleSpecificationSelect}>
{translate('Custom')}
</Button>
<Menu className={styles.presetsMenu}>
<Button className={styles.presetsMenuButton} size={sizes.SMALL}>
{translate('Presets')}
</Button>
<MenuContent>
{presets.map((preset, index) => {
return (
<AddSpecificationPresetMenuItem
key={index}
name={preset.name}
implementation={implementation}
onPress={handleSpecificationSelect}
/>
);
})}
</MenuContent>
</Menu>
</span>
) : null}
{infoLink ? (
<Button to={infoLink} size={sizes.SMALL}>
{translate('MoreInfo')}
</Button>
) : null}
</div>
</div>
</div>
);
}

@ -1,25 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AddSpecificationModalContent from './AddSpecificationModalContent';
function AddSpecificationModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<AddSpecificationModalContent
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
AddSpecificationModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddSpecificationModal;

@ -0,0 +1,21 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AddSpecificationModalContent from './AddSpecificationModalContent';
interface AddSpecificationModalProps {
isOpen: boolean;
onModalClose: (options?: { specificationSelected: boolean }) => void;
}
function AddSpecificationModal({
isOpen,
onModalClose,
}: AddSpecificationModalProps) {
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<AddSpecificationModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default AddSpecificationModal;

@ -1,106 +0,0 @@
import PropTypes from 'prop-types';
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import {
fetchAutoTaggingSpecificationSchema,
selectAutoTaggingSpecificationSchema
} from 'Store/Actions/settingsActions';
import translate from 'Utilities/String/translate';
import AddSpecificationItem from './AddSpecificationItem';
import styles from './AddSpecificationModalContent.css';
export default function AddSpecificationModalContent(props) {
const {
onModalClose
} = props;
const {
isSchemaFetching,
isSchemaPopulated,
schemaError,
schema
} = useSelector(
(state) => state.settings.autoTaggingSpecifications
);
const dispatch = useDispatch();
const onSpecificationSelect = useCallback(({ implementation, name }) => {
dispatch(selectAutoTaggingSpecificationSchema({ implementation, presetName: name }));
onModalClose({ specificationSelected: true });
}, [dispatch, onModalClose]);
useEffect(() => {
dispatch(fetchAutoTaggingSpecificationSchema());
}, [dispatch]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('AddCondition')}
</ModalHeader>
<ModalBody>
{
isSchemaFetching ? <LoadingIndicator /> : null
}
{
!isSchemaFetching && !!schemaError ?
<Alert kind={kinds.DANGER}>
{translate('AddConditionError')}
</Alert> :
null
}
{
isSchemaPopulated && !schemaError ?
<div>
<Alert kind={kinds.INFO}>
<div>
{translate('SupportedAutoTaggingProperties')}
</div>
</Alert>
<div className={styles.specifications}>
{
schema.map((specification) => {
return (
<AddSpecificationItem
key={specification.implementation}
{...specification}
onSpecificationSelect={onSpecificationSelect}
/>
);
})
}
</div>
</div> :
null
}
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
AddSpecificationModalContent.propTypes = {
onModalClose: PropTypes.func.isRequired
};

@ -0,0 +1,90 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import {
fetchAutoTaggingSpecificationSchema,
selectAutoTaggingSpecificationSchema,
} from 'Store/Actions/settingsActions';
import translate from 'Utilities/String/translate';
import AddSpecificationItem from './AddSpecificationItem';
import styles from './AddSpecificationModalContent.css';
interface AddSpecificationModalContentProps {
onModalClose: (options?: { specificationSelected: boolean }) => void;
}
export default function AddSpecificationModalContent({
onModalClose,
}: AddSpecificationModalContentProps) {
const { isSchemaFetching, isSchemaPopulated, schemaError, schema } =
useSelector((state: AppState) => state.settings.autoTaggingSpecifications);
const dispatch = useDispatch();
const onSpecificationSelect = useCallback(
({ implementation }: { implementation: string }) => {
dispatch(
selectAutoTaggingSpecificationSchema({
implementation,
presetName: name,
})
);
onModalClose({ specificationSelected: true });
},
[dispatch, onModalClose]
);
const handleModalClose = useCallback(() => {
onModalClose();
}, [onModalClose]);
useEffect(() => {
dispatch(fetchAutoTaggingSpecificationSchema());
}, [dispatch]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('AddCondition')}</ModalHeader>
<ModalBody>
{isSchemaFetching ? <LoadingIndicator /> : null}
{!isSchemaFetching && !!schemaError ? (
<Alert kind={kinds.DANGER}>{translate('AddConditionError')}</Alert>
) : null}
{isSchemaPopulated && !schemaError ? (
<div>
<Alert kind={kinds.INFO}>
<div>{translate('SupportedAutoTaggingProperties')}</div>
</Alert>
<div className={styles.specifications}>
{schema.map((specification) => {
return (
<AddSpecificationItem
key={specification.implementation}
{...specification}
onSpecificationSelect={onSpecificationSelect}
/>
);
})}
</div>
</div>
) : null}
</ModalBody>
<ModalFooter>
<Button onPress={handleModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}

@ -1,34 +0,0 @@
import PropTypes from 'prop-types';
import React, { useCallback } from 'react';
import MenuItem from 'Components/Menu/MenuItem';
export default function AddSpecificationPresetMenuItem(props) {
const {
name,
implementation,
onPress,
...otherProps
} = props;
const onWrappedPress = useCallback(() => {
onPress({
name,
implementation
});
}, [name, implementation, onPress]);
return (
<MenuItem
{...otherProps}
onPress={onWrappedPress}
>
{name}
</MenuItem>
);
}
AddSpecificationPresetMenuItem.propTypes = {
name: PropTypes.string.isRequired,
implementation: PropTypes.string.isRequired,
onPress: PropTypes.func.isRequired
};

@ -0,0 +1,34 @@
import React, { useCallback } from 'react';
import MenuItem from 'Components/Menu/MenuItem';
interface AddSpecificationPresetMenuItemProps {
name: string;
implementation: string;
onPress: ({
name,
implementation,
}: {
name: string;
implementation: string;
}) => void;
}
export default function AddSpecificationPresetMenuItem({
name,
implementation,
onPress,
...otherProps
}: AddSpecificationPresetMenuItemProps) {
const handlePress = useCallback(() => {
onPress({
name,
implementation,
});
}, [name, implementation, onPress]);
return (
<MenuItem {...otherProps} onPress={handlePress}>
{name}
</MenuItem>
);
}

@ -1,25 +1,34 @@
import PropTypes from 'prop-types';
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditSpecificationModalContent from './EditSpecificationModalContent';
import EditSpecificationModalContent, {
EditSpecificationModalContentProps,
} from './EditSpecificationModalContent';
function EditSpecificationModal({ isOpen, onModalClose, ...otherProps }) {
interface EditSpecificationModalProps
extends EditSpecificationModalContentProps {
isOpen: boolean;
onModalClose: () => void;
}
function EditSpecificationModal({
isOpen,
onModalClose,
...otherProps
}: EditSpecificationModalProps) {
const dispatch = useDispatch();
const onWrappedModalClose = useCallback(() => {
dispatch(clearPendingChanges({ section: 'settings.autoTaggingSpecifications' }));
dispatch(
clearPendingChanges({ section: 'settings.autoTaggingSpecifications' })
);
onModalClose();
}, [onModalClose, dispatch]);
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={onModalClose}>
<EditSpecificationModalContent
{...otherProps}
onModalClose={onWrappedModalClose}
@ -28,9 +37,4 @@ function EditSpecificationModal({ isOpen, onModalClose, ...otherProps }) {
);
}
EditSpecificationModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditSpecificationModal;

@ -1,190 +0,0 @@
import PropTypes from 'prop-types';
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import {
clearAutoTaggingSpecificationPending,
saveAutoTaggingSpecification,
setAutoTaggingSpecificationFieldValue,
setAutoTaggingSpecificationValue
} from 'Store/Actions/settingsActions';
import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector';
import translate from 'Utilities/String/translate';
import styles from './EditSpecificationModalContent.css';
function EditSpecificationModalContent(props) {
const {
id,
onDeleteSpecificationPress,
onModalClose
} = props;
const advancedSettings = useSelector((state) => state.settings.advancedSettings);
const {
item,
...otherFormProps
} = useSelector(
createProviderSettingsSelectorHook('autoTaggingSpecifications', id)
);
const dispatch = useDispatch();
const onInputChange = useCallback(({ name, value }) => {
dispatch(setAutoTaggingSpecificationValue({ name, value }));
}, [dispatch]);
const onFieldChange = useCallback(({ name, value }) => {
dispatch(setAutoTaggingSpecificationFieldValue({ name, value }));
}, [dispatch]);
const onCancelPress = useCallback(({ name, value }) => {
dispatch(clearAutoTaggingSpecificationPending());
onModalClose();
}, [dispatch, onModalClose]);
const onSavePress = useCallback(({ name, value }) => {
dispatch(saveAutoTaggingSpecification({ id }));
onModalClose();
}, [dispatch, id, onModalClose]);
const {
implementationName,
name,
negate,
required,
fields
} = item;
return (
<ModalContent onModalClose={onCancelPress}>
<ModalHeader>
{id ? translate('EditConditionImplementation', { implementationName }) : translate('AddConditionImplementation', { implementationName })}
</ModalHeader>
<ModalBody>
<Form
{...otherFormProps}
>
{
fields && fields.some((x) => x.label === 'Regular Expression') &&
<Alert kind={kinds.INFO}>
<div>
<InlineMarkdown data={translate('ConditionUsingRegularExpressions')} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsTutorialLink', { url: 'https://www.regular-expressions.info/tutorial.html' })} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsCanBeTested', { url: 'http://regexstorm.net/tester' })} />
</div>
</Alert>
}
<FormGroup>
<FormLabel>
{translate('Name')}
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
{
fields && fields.map((field) => {
return (
<ProviderFieldFormGroup
key={field.name}
advancedSettings={advancedSettings}
provider="specifications"
providerData={item}
{...field}
onChange={onFieldChange}
/>
);
})
}
<FormGroup>
<FormLabel>
{translate('Negate')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="negate"
{...negate}
helpText={translate('AutoTaggingNegateHelpText', { implementationName })}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('Required')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="required"
{...required}
helpText={translate('AutoTaggingRequiredHelpText', { implementationName })}
onChange={onInputChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
{
id ?
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteSpecificationPress}
>
{translate('Delete')}
</Button> :
null
}
<Button
onPress={onCancelPress}
>
{translate('Cancel')}
</Button>
<SpinnerErrorButton
isSpinning={false}
onPress={onSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
EditSpecificationModalContent.propTypes = {
id: PropTypes.number,
onDeleteSpecificationPress: PropTypes.func,
onModalClose: PropTypes.func.isRequired
};
export default EditSpecificationModalContent;

@ -0,0 +1,192 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { AutoTaggingSpecificationAppState } from 'App/State/SettingsAppState';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import {
clearAutoTaggingSpecificationPending,
saveAutoTaggingSpecification,
setAutoTaggingSpecificationFieldValue,
setAutoTaggingSpecificationValue,
} from 'Store/Actions/settingsActions';
import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector';
import { AutoTaggingSpecification } from 'typings/AutoTagging';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import styles from './EditSpecificationModalContent.css';
export interface EditSpecificationModalContentProps {
id?: number;
onDeleteSpecificationPress?: () => void;
onModalClose: () => void;
}
function EditSpecificationModalContent({
id,
onDeleteSpecificationPress,
onModalClose,
}: EditSpecificationModalContentProps) {
const advancedSettings = useSelector(
(state: AppState) => state.settings.advancedSettings
);
const { item, ...otherFormProps } = useSelector(
createProviderSettingsSelectorHook<
AutoTaggingSpecification,
AutoTaggingSpecificationAppState
>('autoTaggingSpecifications', id)
);
const dispatch = useDispatch();
const onInputChange = useCallback(
({ name, value }: InputChanged) => {
// @ts-expect-error - actions are not typed
dispatch(setAutoTaggingSpecificationValue({ name, value }));
},
[dispatch]
);
const onFieldChange = useCallback(
({ name, value }: InputChanged) => {
// @ts-expect-error - actions are not typed
dispatch(setAutoTaggingSpecificationFieldValue({ name, value }));
},
[dispatch]
);
const onCancelPress = useCallback(() => {
dispatch(clearAutoTaggingSpecificationPending());
onModalClose();
}, [dispatch, onModalClose]);
const onSavePress = useCallback(() => {
dispatch(saveAutoTaggingSpecification({ id }));
onModalClose();
}, [dispatch, id, onModalClose]);
const { implementationName, name, negate, required, fields } = item;
return (
<ModalContent onModalClose={onCancelPress}>
<ModalHeader>
{id
? translate('EditConditionImplementation', { implementationName })
: translate('AddConditionImplementation', { implementationName })}
</ModalHeader>
<ModalBody>
<Form {...otherFormProps}>
{fields && fields.some((x) => x.label === 'Regular Expression') && (
<Alert kind={kinds.INFO}>
<div>
<InlineMarkdown
data={translate('ConditionUsingRegularExpressions')}
/>
</div>
<div>
<InlineMarkdown
data={translate('RegularExpressionsTutorialLink', {
url: 'https://www.regular-expressions.info/tutorial.html',
})}
/>
</div>
<div>
<InlineMarkdown
data={translate('RegularExpressionsCanBeTested', {
url: 'http://regexstorm.net/tester',
})}
/>
</div>
</Alert>
)}
<FormGroup>
<FormLabel>{translate('Name')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
{fields &&
fields.map((field) => {
return (
<ProviderFieldFormGroup
key={field.name}
advancedSettings={advancedSettings}
provider="specifications"
providerData={item}
{...field}
onChange={onFieldChange}
/>
);
})}
<FormGroup>
<FormLabel>{translate('Negate')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="negate"
{...negate}
helpText={translate('AutoTaggingNegateHelpText', {
implementationName,
})}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Required')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="required"
{...required}
helpText={translate('AutoTaggingRequiredHelpText', {
implementationName,
})}
onChange={onInputChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
{id ? (
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteSpecificationPress}
>
{translate('Delete')}
</Button>
) : null}
<Button onPress={onCancelPress}>{translate('Cancel')}</Button>
<SpinnerErrorButton isSpinning={false} onPress={onSavePress}>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
export default EditSpecificationModalContent;

@ -1,78 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearAutoTaggingSpecificationPending, saveAutoTaggingSpecification, setAutoTaggingSpecificationFieldValue, setAutoTaggingSpecificationValue } from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import EditSpecificationModalContent from './EditSpecificationModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
createProviderSettingsSelector('autoTaggingSpecifications'),
(advancedSettings, specification) => {
return {
advancedSettings,
...specification
};
}
);
}
const mapDispatchToProps = {
setAutoTaggingSpecificationValue,
setAutoTaggingSpecificationFieldValue,
saveAutoTaggingSpecification,
clearAutoTaggingSpecificationPending
};
class EditSpecificationModalContentConnector extends Component {
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setAutoTaggingSpecificationValue({ name, value });
};
onFieldChange = ({ name, value }) => {
this.props.setAutoTaggingSpecificationFieldValue({ name, value });
};
onCancelPress = () => {
this.props.clearAutoTaggingSpecificationPending();
this.props.onModalClose();
};
onSavePress = () => {
this.props.saveAutoTaggingSpecification({ id: this.props.id });
this.props.onModalClose();
};
//
// Render
render() {
return (
<EditSpecificationModalContent
{...this.props}
onCancelPress={this.onCancelPress}
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
);
}
}
EditSpecificationModalContentConnector.propTypes = {
id: PropTypes.number,
item: PropTypes.object.isRequired,
setAutoTaggingSpecificationValue: PropTypes.func.isRequired,
setAutoTaggingSpecificationFieldValue: PropTypes.func.isRequired,
clearAutoTaggingSpecificationPending: PropTypes.func.isRequired,
saveAutoTaggingSpecification: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditSpecificationModalContentConnector);

@ -1,25 +1,35 @@
import PropTypes from 'prop-types';
import React, { useCallback, useState } from 'react';
import Card from 'Components/Card';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import { icons, kinds } from 'Helpers/Props';
import Field from 'typings/Field';
import translate from 'Utilities/String/translate';
import EditSpecificationModal from './EditSpecificationModal';
import styles from './Specification.css';
export default function Specification(props) {
const {
id,
implementationName,
name,
required,
negate,
onConfirmDeleteSpecification,
onCloneSpecificationPress
} = props;
interface SpecificationProps {
id: number;
implementation: string;
implementationName: string;
name: string;
negate: boolean;
required: boolean;
fields: Field[];
onConfirmDeleteSpecification: (specId: number) => void;
onCloneSpecificationPress: (specId: number) => void;
}
export default function Specification({
id,
implementationName,
name,
required,
negate,
onConfirmDeleteSpecification,
onCloneSpecificationPress,
}: SpecificationProps) {
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@ -55,9 +65,7 @@ export default function Specification(props) {
onPress={onEditPress}
>
<div className={styles.nameContainer}>
<div className={styles.name}>
{name}
</div>
<div className={styles.name}>{name}</div>
<IconButton
className={styles.cloneButton}
@ -68,25 +76,15 @@ export default function Specification(props) {
</div>
<div className={styles.labels}>
<Label kind={kinds.DEFAULT}>
{implementationName}
</Label>
<Label kind={kinds.DEFAULT}>{implementationName}</Label>
{
negate ?
<Label kind={kinds.DANGER}>
{translate('Negated')}
</Label> :
null
}
{negate ? (
<Label kind={kinds.DANGER}>{translate('Negated')}</Label>
) : null}
{
required ?
<Label kind={kinds.SUCCESS}>
{translate('Required')}
</Label> :
null
}
{required ? (
<Label kind={kinds.SUCCESS}>{translate('Required')}</Label>
) : null}
</div>
<EditSpecificationModal
@ -108,15 +106,3 @@ export default function Specification(props) {
</Card>
);
}
Specification.propTypes = {
id: PropTypes.number.isRequired,
implementation: PropTypes.string.isRequired,
implementationName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
negate: PropTypes.bool.isRequired,
required: PropTypes.bool.isRequired,
fields: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteSpecification: PropTypes.func.isRequired,
onCloneSpecificationPress: PropTypes.func.isRequired
};

@ -1,48 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
function TagDetailsDelayProfile(props) {
const {
preferredProtocol,
enableUsenet,
enableTorrent,
usenetDelay,
torrentDelay
} = props;
return (
<div>
<div>
{titleCase(translate('DelayProfileProtocol', { preferredProtocol }))}
</div>
<div>
{
enableUsenet ?
translate('UsenetDelayTime', { usenetDelay }) :
translate('UsenetDisabled')
}
</div>
<div>
{
enableTorrent ?
translate('TorrentDelayTime', { torrentDelay }) :
translate('TorrentsDisabled')
}
</div>
</div>
);
}
TagDetailsDelayProfile.propTypes = {
preferredProtocol: PropTypes.string.isRequired,
enableUsenet: PropTypes.bool.isRequired,
enableTorrent: PropTypes.bool.isRequired,
usenetDelay: PropTypes.number.isRequired,
torrentDelay: PropTypes.number.isRequired
};
export default TagDetailsDelayProfile;

@ -0,0 +1,41 @@
import React from 'react';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
interface TagDetailsDelayProfileProps {
preferredProtocol: string;
enableUsenet: boolean;
enableTorrent: boolean;
usenetDelay: number;
torrentDelay: number;
}
function TagDetailsDelayProfile({
preferredProtocol,
enableUsenet,
enableTorrent,
usenetDelay,
torrentDelay,
}: TagDetailsDelayProfileProps) {
return (
<div>
<div>
{titleCase(translate('DelayProfileProtocol', { preferredProtocol }))}
</div>
<div>
{enableUsenet
? translate('UsenetDelayTime', { usenetDelay })
: translate('UsenetDisabled')}
</div>
<div>
{enableTorrent
? translate('TorrentDelayTime', { torrentDelay })
: translate('TorrentsDisabled')}
</div>
</div>
);
}
export default TagDetailsDelayProfile;

@ -1,33 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import TagDetailsModalContentConnector from './TagDetailsModalContentConnector';
function TagDetailsModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
size={sizes.SMALL}
isOpen={isOpen}
onModalClose={onModalClose}
>
<TagDetailsModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
TagDetailsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default TagDetailsModal;

@ -0,0 +1,25 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import TagDetailsModalContent, {
TagDetailsModalContentProps,
} from './TagDetailsModalContent';
interface TagDetailsModalProps extends TagDetailsModalContentProps {
isOpen: boolean;
onModalClose: () => void;
}
function TagDetailsModal({
isOpen,
onModalClose,
...otherProps
}: TagDetailsModalProps) {
return (
<Modal size={sizes.SMALL} isOpen={isOpen} onModalClose={onModalClose}>
<TagDetailsModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default TagDetailsModal;

@ -1,257 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import FieldSet from 'Components/FieldSet';
import Label from 'Components/Label';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import TagDetailsDelayProfile from './TagDetailsDelayProfile';
import styles from './TagDetailsModalContent.css';
function TagDetailsModalContent(props) {
const {
label,
isTagUsed,
series,
delayProfiles,
importLists,
notifications,
releaseProfiles,
indexers,
downloadClients,
autoTags,
onModalClose,
onDeleteTagPress
} = props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('TagDetails', { label })}
</ModalHeader>
<ModalBody>
{
!isTagUsed &&
<div>
{translate('TagIsNotUsedAndCanBeDeleted')}
</div>
}
{
series.length ?
<FieldSet legend={translate('Series')}>
{
series.map((item) => {
return (
<div key={item.id}>
{item.title}
</div>
);
})
}
</FieldSet> :
null
}
{
delayProfiles.length ?
<FieldSet legend={translate('DelayProfile')}>
{
delayProfiles.map((item) => {
const {
id,
preferredProtocol,
enableUsenet,
enableTorrent,
usenetDelay,
torrentDelay
} = item;
return (
<TagDetailsDelayProfile
key={id}
preferredProtocol={preferredProtocol}
enableUsenet={enableUsenet}
enableTorrent={enableTorrent}
usenetDelay={usenetDelay}
torrentDelay={torrentDelay}
/>
);
})
}
</FieldSet> :
null
}
{
notifications.length ?
<FieldSet legend={translate('Connections')}>
{
notifications.map((item) => {
return (
<div key={item.id}>
{item.name}
</div>
);
})
}
</FieldSet> :
null
}
{
importLists.length ?
<FieldSet legend={translate('ImportLists')}>
{
importLists.map((item) => {
return (
<div key={item.id}>
{item.name}
</div>
);
})
}
</FieldSet> :
null
}
{
releaseProfiles.length ?
<FieldSet legend={translate('ReleaseProfiles')}>
{
releaseProfiles.map((item) => {
return (
<div
key={item.id}
className={styles.restriction}
>
<div>
{
item.required.map((r) => {
return (
<Label
key={r}
kind={kinds.SUCCESS}
>
{r}
</Label>
);
})
}
</div>
<div>
{
item.ignored.map((i) => {
return (
<Label
key={i}
kind={kinds.DANGER}
>
{i}
</Label>
);
})
}
</div>
</div>
);
})
}
</FieldSet> :
null
}
{
indexers.length ?
<FieldSet legend={translate('Indexers')}>
{
indexers.map((item) => {
return (
<div key={item.id}>
{item.name}
</div>
);
})
}
</FieldSet> :
null
}
{
downloadClients.length ?
<FieldSet legend={translate('DownloadClients')}>
{
downloadClients.map((item) => {
return (
<div key={item.id}>
{item.name}
</div>
);
})
}
</FieldSet> :
null
}
{
autoTags.length ?
<FieldSet legend={translate('AutoTagging')}>
{
autoTags.map((item) => {
return (
<div key={item.id}>
{item.name}
</div>
);
})
}
</FieldSet> :
null
}
</ModalBody>
<ModalFooter>
{
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
title={isTagUsed ? translate('TagCannotBeDeletedWhileInUse') : undefined}
isDisabled={isTagUsed}
onPress={onDeleteTagPress}
>
{translate('Delete')}
</Button>
}
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
TagDetailsModalContent.propTypes = {
label: PropTypes.string.isRequired,
isTagUsed: PropTypes.bool.isRequired,
series: PropTypes.arrayOf(PropTypes.object).isRequired,
delayProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
importLists: PropTypes.arrayOf(PropTypes.object).isRequired,
notifications: PropTypes.arrayOf(PropTypes.object).isRequired,
releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
indexers: PropTypes.arrayOf(PropTypes.object).isRequired,
downloadClients: PropTypes.arrayOf(PropTypes.object).isRequired,
autoTags: PropTypes.arrayOf(PropTypes.object).isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteTagPress: PropTypes.func.isRequired
};
export default TagDetailsModalContent;

@ -0,0 +1,269 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import ModelBase from 'App/ModelBase';
import AppState from 'App/State/AppState';
import FieldSet from 'Components/FieldSet';
import Label from 'Components/Label';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import translate from 'Utilities/String/translate';
import TagDetailsDelayProfile from './TagDetailsDelayProfile';
import styles from './TagDetailsModalContent.css';
function findMatchingItems<T extends ModelBase>(ids: number[], items: T[]) {
return items.filter((s) => {
return ids.includes(s.id);
});
}
function createUnorderedMatchingSeriesSelector(seriesIds: number[]) {
return createSelector(createAllSeriesSelector(), (series) =>
findMatchingItems(seriesIds, series)
);
}
function createMatchingSeriesSelector(seriesIds: number[]) {
return createSelector(
createUnorderedMatchingSeriesSelector(seriesIds),
(series) => {
return series.sort((seriesA, seriesB) => {
const sortTitleA = seriesA.sortTitle;
const sortTitleB = seriesB.sortTitle;
if (sortTitleA > sortTitleB) {
return 1;
} else if (sortTitleA < sortTitleB) {
return -1;
}
return 0;
});
}
);
}
function createMatchingItemSelector<T extends ModelBase>(
ids: number[],
selector: (state: AppState) => T[]
) {
return createSelector(selector, (items) => findMatchingItems<T>(ids, items));
}
export interface TagDetailsModalContentProps {
label: string;
isTagUsed: boolean;
delayProfileIds: number[];
importListIds: number[];
notificationIds: number[];
restrictionIds: number[];
indexerIds: number[];
downloadClientIds: number[];
autoTagIds: number[];
seriesIds: number[];
onModalClose: () => void;
onDeleteTagPress: () => void;
}
function TagDetailsModalContent({
label,
isTagUsed,
delayProfileIds = [],
importListIds = [],
notificationIds = [],
restrictionIds = [],
indexerIds = [],
downloadClientIds = [],
autoTagIds = [],
seriesIds = [],
onModalClose,
onDeleteTagPress,
}: TagDetailsModalContentProps) {
const series = useSelector(createMatchingSeriesSelector(seriesIds));
const delayProfiles = useSelector(
createMatchingItemSelector(
delayProfileIds,
(state: AppState) => state.settings.delayProfiles.items
)
);
const importLists = useSelector(
createMatchingItemSelector(
importListIds,
(state: AppState) => state.settings.importLists.items
)
);
const notifications = useSelector(
createMatchingItemSelector(
notificationIds,
(state: AppState) => state.settings.notifications.items
)
);
const releaseProfiles = useSelector(
createMatchingItemSelector(
restrictionIds,
(state: AppState) => state.settings.releaseProfiles.items
)
);
const indexers = useSelector(
createMatchingItemSelector(
indexerIds,
(state: AppState) => state.settings.indexers.items
)
);
const downloadClients = useSelector(
createMatchingItemSelector(
downloadClientIds,
(state: AppState) => state.settings.downloadClients.items
)
);
const autoTags = useSelector(
createMatchingItemSelector(
autoTagIds,
(state: AppState) => state.settings.autoTaggings.items
)
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('TagDetails', { label })}</ModalHeader>
<ModalBody>
{!isTagUsed && <div>{translate('TagIsNotUsedAndCanBeDeleted')}</div>}
{series.length ? (
<FieldSet legend={translate('Series')}>
{series.map((item) => {
return <div key={item.id}>{item.title}</div>;
})}
</FieldSet>
) : null}
{delayProfiles.length ? (
<FieldSet legend={translate('DelayProfile')}>
{delayProfiles.map((item) => {
const {
id,
preferredProtocol,
enableUsenet,
enableTorrent,
usenetDelay,
torrentDelay,
} = item;
return (
<TagDetailsDelayProfile
key={id}
preferredProtocol={preferredProtocol}
enableUsenet={enableUsenet}
enableTorrent={enableTorrent}
usenetDelay={usenetDelay}
torrentDelay={torrentDelay}
/>
);
})}
</FieldSet>
) : null}
{notifications.length ? (
<FieldSet legend={translate('Connections')}>
{notifications.map((item) => {
return <div key={item.id}>{item.name}</div>;
})}
</FieldSet>
) : null}
{importLists.length ? (
<FieldSet legend={translate('ImportLists')}>
{importLists.map((item) => {
return <div key={item.id}>{item.name}</div>;
})}
</FieldSet>
) : null}
{releaseProfiles.length ? (
<FieldSet legend={translate('ReleaseProfiles')}>
{releaseProfiles.map((item) => {
return (
<div key={item.id} className={styles.restriction}>
<div>
{item.required.map((r) => {
return (
<Label key={r} kind={kinds.SUCCESS}>
{r}
</Label>
);
})}
</div>
<div>
{item.ignored.map((i) => {
return (
<Label key={i} kind={kinds.DANGER}>
{i}
</Label>
);
})}
</div>
</div>
);
})}
</FieldSet>
) : null}
{indexers.length ? (
<FieldSet legend={translate('Indexers')}>
{indexers.map((item) => {
return <div key={item.id}>{item.name}</div>;
})}
</FieldSet>
) : null}
{downloadClients.length ? (
<FieldSet legend={translate('DownloadClients')}>
{downloadClients.map((item) => {
return <div key={item.id}>{item.name}</div>;
})}
</FieldSet>
) : null}
{autoTags.length ? (
<FieldSet legend={translate('AutoTagging')}>
{autoTags.map((item) => {
return <div key={item.id}>{item.name}</div>;
})}
</FieldSet>
) : null}
</ModalBody>
<ModalFooter>
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
title={
isTagUsed ? translate('TagCannotBeDeletedWhileInUse') : undefined
}
isDisabled={isTagUsed}
onPress={onDeleteTagPress}
>
{translate('Delete')}
</Button>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default TagDetailsModalContent;

@ -1,121 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import TagDetailsModalContent from './TagDetailsModalContent';
function findMatchingItems(ids, items) {
return items.filter((s) => {
return ids.includes(s.id);
});
}
function createUnorderedMatchingSeriesSelector() {
return createSelector(
(state, { seriesIds }) => seriesIds,
createAllSeriesSelector(),
findMatchingItems
);
}
function createMatchingSeriesSelector() {
return createSelector(
createUnorderedMatchingSeriesSelector(),
(series) => {
return series.sort((seriesA, seriesB) => {
const sortTitleA = seriesA.sortTitle;
const sortTitleB = seriesB.sortTitle;
if (sortTitleA > sortTitleB) {
return 1;
} else if (sortTitleA < sortTitleB) {
return -1;
}
return 0;
});
}
);
}
function createMatchingDelayProfilesSelector() {
return createSelector(
(state, { delayProfileIds }) => delayProfileIds,
(state) => state.settings.delayProfiles.items,
findMatchingItems
);
}
function createMatchingImportListsSelector() {
return createSelector(
(state, { importListIds }) => importListIds,
(state) => state.settings.importLists.items,
findMatchingItems
);
}
function createMatchingNotificationsSelector() {
return createSelector(
(state, { notificationIds }) => notificationIds,
(state) => state.settings.notifications.items,
findMatchingItems
);
}
function createMatchingReleaseProfilesSelector() {
return createSelector(
(state, { restrictionIds }) => restrictionIds,
(state) => state.settings.releaseProfiles.items,
findMatchingItems
);
}
function createMatchingIndexersSelector() {
return createSelector(
(state, { indexerIds }) => indexerIds,
(state) => state.settings.indexers.items,
findMatchingItems
);
}
function createMatchingDownloadClientsSelector() {
return createSelector(
(state, { downloadClientIds }) => downloadClientIds,
(state) => state.settings.downloadClients.items,
findMatchingItems
);
}
function createMatchingAutoTagsSelector() {
return createSelector(
(state, { autoTagIds }) => autoTagIds,
(state) => state.settings.autoTaggings.items,
findMatchingItems
);
}
function createMapStateToProps() {
return createSelector(
createMatchingSeriesSelector(),
createMatchingDelayProfilesSelector(),
createMatchingImportListsSelector(),
createMatchingNotificationsSelector(),
createMatchingReleaseProfilesSelector(),
createMatchingIndexersSelector(),
createMatchingDownloadClientsSelector(),
createMatchingAutoTagsSelector(),
(series, delayProfiles, importLists, notifications, releaseProfiles, indexers, downloadClients, autoTags) => {
return {
series,
delayProfiles,
importLists,
notifications,
releaseProfiles,
indexers,
downloadClients,
autoTags
};
}
);
}
export default connect(createMapStateToProps)(TagDetailsModalContent);

@ -1,207 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Card from 'Components/Card';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import TagDetailsModal from './Details/TagDetailsModal';
import TagInUse from './TagInUse';
import styles from './Tag.css';
class Tag extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false,
isDeleteTagModalOpen: false
};
}
//
// Listeners
onShowDetailsPress = () => {
this.setState({ isDetailsModalOpen: true });
};
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false });
};
onDeleteTagPress = () => {
this.setState({
isDetailsModalOpen: false,
isDeleteTagModalOpen: true
});
};
onDeleteTagModalClose = () => {
this.setState({ isDeleteTagModalOpen: false });
};
onConfirmDeleteTag = () => {
this.props.onConfirmDeleteTag({ id: this.props.id });
};
//
// Render
render() {
const {
label,
delayProfileIds,
importListIds,
notificationIds,
restrictionIds,
indexerIds,
downloadClientIds,
autoTagIds,
seriesIds
} = this.props;
const {
isDetailsModalOpen,
isDeleteTagModalOpen
} = this.state;
const isTagUsed = !!(
delayProfileIds.length ||
importListIds.length ||
notificationIds.length ||
restrictionIds.length ||
indexerIds.length ||
downloadClientIds.length ||
autoTagIds.length ||
seriesIds.length
);
return (
<Card
className={styles.tag}
overlayContent={true}
onPress={this.onShowDetailsPress}
>
<div className={styles.label}>
{label}
</div>
{
isTagUsed ?
<div>
<TagInUse
label={translate('Series')}
count={seriesIds.length}
/>
<TagInUse
label={translate('DelayProfile')}
labelPlural={translate('DelayProfiles')}
count={delayProfileIds.length}
/>
<TagInUse
label={translate('ImportList')}
labelPlural={translate('ImportLists')}
count={importListIds.length}
/>
<TagInUse
label={translate('Connection')}
labelPlural={translate('Connections')}
count={notificationIds.length}
/>
<TagInUse
label={translate('ReleaseProfile')}
labelPlural={translate('ReleaseProfiles')}
count={restrictionIds.length}
/>
<TagInUse
label={translate('Indexer')}
labelPlural={translate('Indexers')}
count={indexerIds.length}
/>
<TagInUse
label={translate('DownloadClient')}
labelPlural={translate('DownloadClients')}
count={downloadClientIds.length}
/>
<TagInUse
label={translate('AutoTagging')}
count={autoTagIds.length}
/>
</div> :
null
}
{
!isTagUsed &&
<div>
{translate('NoLinks')}
</div>
}
<TagDetailsModal
label={label}
isTagUsed={isTagUsed}
seriesIds={seriesIds}
delayProfileIds={delayProfileIds}
importListIds={importListIds}
notificationIds={notificationIds}
restrictionIds={restrictionIds}
indexerIds={indexerIds}
downloadClientIds={downloadClientIds}
autoTagIds={autoTagIds}
isOpen={isDetailsModalOpen}
onModalClose={this.onDetailsModalClose}
onDeleteTagPress={this.onDeleteTagPress}
/>
<ConfirmModal
isOpen={isDeleteTagModalOpen}
kind={kinds.DANGER}
title={translate('DeleteTag')}
message={translate('DeleteTagMessageText', { label })}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteTag}
onCancel={this.onDeleteTagModalClose}
/>
</Card>
);
}
}
Tag.propTypes = {
id: PropTypes.number.isRequired,
label: PropTypes.string.isRequired,
delayProfileIds: PropTypes.arrayOf(PropTypes.number).isRequired,
importListIds: PropTypes.arrayOf(PropTypes.number).isRequired,
notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired,
restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired,
indexerIds: PropTypes.arrayOf(PropTypes.number).isRequired,
downloadClientIds: PropTypes.arrayOf(PropTypes.number).isRequired,
autoTagIds: PropTypes.arrayOf(PropTypes.number).isRequired,
seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired,
onConfirmDeleteTag: PropTypes.func.isRequired
};
Tag.defaultProps = {
delayProfileIds: [],
importListIds: [],
notificationIds: [],
restrictionIds: [],
indexerIds: [],
downloadClientIds: [],
autoTagIds: [],
seriesIds: []
};
export default Tag;

@ -0,0 +1,151 @@
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 translate from 'Utilities/String/translate';
import TagDetailsModal from './Details/TagDetailsModal';
import TagInUse from './TagInUse';
import styles from './Tag.css';
interface TagProps {
id: number;
label: string;
}
function Tag({ id, label }: TagProps) {
const dispatch = useDispatch();
const {
delayProfileIds = [],
importListIds = [],
notificationIds = [],
restrictionIds = [],
indexerIds = [],
downloadClientIds = [],
autoTagIds = [],
seriesIds = [],
} = useSelector(createTagDetailsSelector(id)) ?? {};
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const [isDeleteTagModalOpen, setIsDeleteTagModalOpen] = useState(false);
const isTagUsed = !!(
delayProfileIds.length ||
importListIds.length ||
notificationIds.length ||
restrictionIds.length ||
indexerIds.length ||
downloadClientIds.length ||
autoTagIds.length ||
seriesIds.length
);
const handleShowDetailsPress = useCallback(() => {
setIsDetailsModalOpen(true);
}, []);
const handeDetailsModalClose = useCallback(() => {
setIsDetailsModalOpen(false);
}, []);
const handleDeleteTagPress = useCallback(() => {
setIsDetailsModalOpen(false);
setIsDeleteTagModalOpen(true);
}, []);
const handleConfirmDeleteTag = useCallback(() => {
setIsDeleteTagModalOpen(false);
}, []);
const handleDeleteTagModalClose = useCallback(() => {
dispatch(deleteTag({ id }));
}, [id, dispatch]);
return (
<Card
className={styles.tag}
overlayContent={true}
onPress={handleShowDetailsPress}
>
<div className={styles.label}>{label}</div>
{isTagUsed ? (
<div>
<TagInUse label={translate('Series')} count={seriesIds.length} />
<TagInUse
label={translate('DelayProfile')}
labelPlural={translate('DelayProfiles')}
count={delayProfileIds.length}
/>
<TagInUse
label={translate('ImportList')}
labelPlural={translate('ImportLists')}
count={importListIds.length}
/>
<TagInUse
label={translate('Connection')}
labelPlural={translate('Connections')}
count={notificationIds.length}
/>
<TagInUse
label={translate('ReleaseProfile')}
labelPlural={translate('ReleaseProfiles')}
count={restrictionIds.length}
/>
<TagInUse
label={translate('Indexer')}
labelPlural={translate('Indexers')}
count={indexerIds.length}
/>
<TagInUse
label={translate('DownloadClient')}
labelPlural={translate('DownloadClients')}
count={downloadClientIds.length}
/>
<TagInUse
label={translate('AutoTagging')}
count={autoTagIds.length}
/>
</div>
) : null}
{!isTagUsed && <div>{translate('NoLinks')}</div>}
<TagDetailsModal
label={label}
isTagUsed={isTagUsed}
seriesIds={seriesIds}
delayProfileIds={delayProfileIds}
importListIds={importListIds}
notificationIds={notificationIds}
restrictionIds={restrictionIds}
indexerIds={indexerIds}
downloadClientIds={downloadClientIds}
autoTagIds={autoTagIds}
isOpen={isDetailsModalOpen}
onModalClose={handeDetailsModalClose}
onDeleteTagPress={handleDeleteTagPress}
/>
<ConfirmModal
isOpen={isDeleteTagModalOpen}
kind={kinds.DANGER}
title={translate('DeleteTag')}
message={translate('DeleteTagMessageText', { label })}
confirmLabel={translate('Delete')}
onConfirm={handleConfirmDeleteTag}
onCancel={handleDeleteTagModalClose}
/>
</Card>
);
}
export default Tag;

@ -1,22 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteTag } from 'Store/Actions/tagActions';
import createTagDetailsSelector from 'Store/Selectors/createTagDetailsSelector';
import Tag from './Tag';
function createMapStateToProps() {
return createSelector(
createTagDetailsSelector(),
(tagDetails) => {
return {
...tagDetails
};
}
);
}
const mapStateToProps = {
onConfirmDeleteTag: deleteTag
};
export default connect(createMapStateToProps, mapStateToProps)(Tag);

@ -1,13 +1,12 @@
import PropTypes from 'prop-types';
import React from 'react';
export default function TagInUse(props) {
const {
label,
labelPlural,
count
} = props;
interface TagInUseProps {
label: string;
labelPlural?: string;
count: number;
}
export default function TagInUse({ label, labelPlural, count }: TagInUseProps) {
if (count === 0) {
return null;
}
@ -26,9 +25,3 @@ export default function TagInUse(props) {
</div>
);
}
TagInUse.propTypes = {
label: PropTypes.string.isRequired,
labelPlural: PropTypes.string,
count: PropTypes.number.isRequired
};

@ -4,17 +4,15 @@ import PageContentBody from 'Components/Page/PageContentBody';
import SettingsToolbar from 'Settings/SettingsToolbar';
import translate from 'Utilities/String/translate';
import AutoTaggings from './AutoTagging/AutoTaggings';
import TagsConnector from './TagsConnector';
import Tags from './Tags';
function TagSettings() {
return (
<PageContent title={translate('Tags')}>
<SettingsToolbar
showSave={false}
/>
<SettingsToolbar showSave={false} />
<PageContentBody>
<TagsConnector />
<Tags />
<AutoTaggings />
</PageContentBody>
</PageContent>

@ -1,54 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import TagConnector from './TagConnector';
import styles from './Tags.css';
function Tags(props) {
const {
items,
...otherProps
} = props;
if (!items.length) {
return (
<Alert kind={kinds.INFO}>
{translate('NoTagsHaveBeenAddedYet')}
</Alert>
);
}
return (
<FieldSet
legend={translate('Tags')}
>
<PageSectionContent
errorMessage={translate('TagsLoadError')}
{...otherProps}
>
<div className={styles.tags}>
{
items.map((item) => {
return (
<TagConnector
key={item.id}
{...item}
/>
);
})
}
</div>
</PageSectionContent>
</FieldSet>
);
}
Tags.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default Tags;

@ -0,0 +1,73 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import TagsAppState, { Tag as TagModel } from 'App/State/TagsAppState';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { kinds } from 'Helpers/Props';
import {
fetchDelayProfiles,
fetchDownloadClients,
fetchImportLists,
fetchIndexers,
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 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: isDetailsFetching,
isPopulated: isDetailsPopulated,
error: detailsError,
} = details;
useEffect(() => {
dispatch(fetchTags());
dispatch(fetchTagDetails());
dispatch(fetchDelayProfiles());
dispatch(fetchImportLists());
dispatch(fetchNotifications());
dispatch(fetchReleaseProfiles());
dispatch(fetchIndexers());
dispatch(fetchDownloadClients());
}, [dispatch]);
if (!items.length) {
return (
<Alert kind={kinds.INFO}>{translate('NoTagsHaveBeenAddedYet')}</Alert>
);
}
return (
<FieldSet legend={translate('Tags')}>
<PageSectionContent
errorMessage={translate('TagsLoadError')}
error={error || detailsError}
isFetching={isFetching || isDetailsFetching}
isPopulated={isPopulated || isDetailsPopulated}
>
<div className={styles.tags}>
{items.map((item) => {
return <Tag key={item.id} {...item} />;
})}
</div>
</PageSectionContent>
</FieldSet>
);
}
export default Tags;

@ -1,90 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchDelayProfiles, fetchDownloadClients, fetchImportLists, fetchIndexers, 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 Tags from './Tags';
function createMapStateToProps() {
return createSelector(
createSortedSectionSelector('tags', sortByProp('label')),
(tags) => {
const isFetching = tags.isFetching || tags.details.isFetching;
const error = tags.error || tags.details.error;
const isPopulated = tags.isPopulated && tags.details.isPopulated;
return {
...tags,
isFetching,
error,
isPopulated
};
}
);
}
const mapDispatchToProps = {
dispatchFetchTags: fetchTags,
dispatchFetchTagDetails: fetchTagDetails,
dispatchFetchDelayProfiles: fetchDelayProfiles,
dispatchFetchImportLists: fetchImportLists,
dispatchFetchNotifications: fetchNotifications,
dispatchFetchReleaseProfiles: fetchReleaseProfiles,
dispatchFetchIndexers: fetchIndexers,
dispatchFetchDownloadClients: fetchDownloadClients
};
class MetadatasConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
dispatchFetchTags,
dispatchFetchTagDetails,
dispatchFetchDelayProfiles,
dispatchFetchImportLists,
dispatchFetchNotifications,
dispatchFetchReleaseProfiles,
dispatchFetchIndexers,
dispatchFetchDownloadClients
} = this.props;
dispatchFetchTags();
dispatchFetchTagDetails();
dispatchFetchDelayProfiles();
dispatchFetchImportLists();
dispatchFetchNotifications();
dispatchFetchReleaseProfiles();
dispatchFetchIndexers();
dispatchFetchDownloadClients();
}
//
// Render
render() {
return (
<Tags
{...this.props}
/>
);
}
}
MetadatasConnector.propTypes = {
dispatchFetchTags: PropTypes.func.isRequired,
dispatchFetchTagDetails: PropTypes.func.isRequired,
dispatchFetchDelayProfiles: PropTypes.func.isRequired,
dispatchFetchImportLists: PropTypes.func.isRequired,
dispatchFetchNotifications: PropTypes.func.isRequired,
dispatchFetchReleaseProfiles: PropTypes.func.isRequired,
dispatchFetchIndexers: PropTypes.func.isRequired,
dispatchFetchDownloadClients: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector);

@ -9,7 +9,7 @@ export default function createEnabledDownloadClientsSelector(
protocol: DownloadProtocol
) {
return createSelector(
createSortedSectionSelector<DownloadClient>(
createSortedSectionSelector<DownloadClient, DownloadClientAppState>(
'settings.downloadClients',
sortByProp('name')
),

@ -1,71 +0,0 @@
import _ from 'lodash';
import { createSelector } from 'reselect';
import selectSettings from 'Store/Selectors/selectSettings';
function selector(id, section) {
if (!id) {
const item = _.isArray(section.schema) ? section.selectedSchema : section.schema;
const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError);
const {
isSchemaFetching: isFetching,
isSchemaPopulated: isPopulated,
schemaError: error,
isSaving,
saveError,
isTesting,
pendingChanges
} = section;
return {
isFetching,
isPopulated,
error,
isSaving,
saveError,
isTesting,
pendingChanges,
...settings,
item: settings.settings
};
}
const {
isFetching,
isPopulated,
error,
isSaving,
saveError,
isTesting,
pendingChanges
} = section;
const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError);
return {
isFetching,
isPopulated,
error,
isSaving,
saveError,
isTesting,
...settings,
item: settings.settings
};
}
export default function createProviderSettingsSelector(sectionName) {
return createSelector(
(state, { id }) => id,
(state) => state.settings[sectionName],
(id, section) => selector(id, section)
);
}
export function createProviderSettingsSelectorHook(sectionName, id) {
return createSelector(
(state) => state.settings[sectionName],
(section) => selector(id, section)
);
}

@ -0,0 +1,100 @@
import _ from 'lodash';
import { createSelector } from 'reselect';
import ModelBase from 'App/ModelBase';
import {
AppSectionProviderState,
AppSectionSchemaState,
} from 'App/State/AppSectionState';
import AppState from 'App/State/AppState';
import selectSettings, {
ModelBaseSetting,
} from 'Store/Selectors/selectSettings';
import getSectionState from 'Utilities/State/getSectionState';
function selector<
T extends ModelBaseSetting,
S extends AppSectionProviderState<T> & AppSectionSchemaState<T>
>(id: number | undefined, section: S) {
if (!id) {
const item = _.isArray(section.schema)
? section.selectedSchema
: section.schema;
const settings = selectSettings(
Object.assign({ name: '' }, item),
section.pendingChanges ?? {},
section.saveError
);
const {
isSchemaFetching: isFetching,
isSchemaPopulated: isPopulated,
schemaError: error,
isSaving,
saveError,
isTesting,
pendingChanges,
} = section;
return {
isFetching,
isPopulated,
error,
isSaving,
saveError,
isTesting,
...settings,
pendingChanges,
item: settings.settings,
};
}
const {
isFetching,
isPopulated,
error,
isSaving,
saveError,
isTesting,
pendingChanges,
} = section;
const item = section.items.find((i) => i.id === id)!;
const settings = selectSettings<T>(item, pendingChanges, saveError);
return {
isFetching,
isPopulated,
error,
isSaving,
saveError,
isTesting,
...settings,
item: settings.settings,
};
}
export default function createProviderSettingsSelector<
T extends ModelBase,
S extends AppSectionProviderState<T> & AppSectionSchemaState<T>
>(sectionName: string) {
// @ts-expect-error - This isn't fully typed
return createSelector(
(_state: AppState, { id }: { id: number }) => id,
(state) => state.settings[sectionName] as S,
(id: number, section: S) => selector(id, section)
);
}
export function createProviderSettingsSelectorHook<
T extends ModelBaseSetting,
S extends AppSectionProviderState<T> & AppSectionSchemaState<T>
>(sectionName: string, id: number | undefined) {
return createSelector(
(state: AppState) => state.settings,
(state) => {
const sectionState = getSectionState(state, sectionName, false) as S;
return selector<T, S>(id, sectionState);
}
);
}

@ -6,7 +6,10 @@ import sortByProp from 'Utilities/Array/sortByProp';
export default function createRootFoldersSelector() {
return createSelector(
createSortedSectionSelector<RootFolder>('rootFolders', sortByProp('path')),
createSortedSectionSelector<RootFolder, RootFolderAppState>(
'rootFolders',
sortByProp('path')
),
(rootFolders: RootFolderAppState) => rootFolders
);
}

@ -1,15 +1,18 @@
import { createSelector } from 'reselect';
import AppSectionState, {
AppSectionProviderState,
} from 'App/State/AppSectionState';
import AppState from 'App/State/AppState';
import getSectionState from 'Utilities/State/getSectionState';
function createSortedSectionSelector<T>(
section: string,
comparer: (a: T, b: T) => number
) {
function createSortedSectionSelector<
T,
S extends AppSectionState<T> | AppSectionProviderState<T>
>(section: string, comparer: (a: T, b: T) => number) {
return createSelector(
(state: AppState) => state,
(state) => {
const sectionState = getSectionState(state, section, true);
const sectionState = getSectionState(state, section, true) as S;
return {
...sectionState,

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

@ -68,14 +68,14 @@ function mapFailure(failure: ValidationFailure): Failure {
};
}
interface ModelBaseSetting {
export interface ModelBaseSetting {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[id: string]: any;
}
function selectSettings<T extends ModelBaseSetting>(
item: T,
pendingChanges: Partial<ModelBaseSetting>,
pendingChanges?: Partial<ModelBaseSetting>,
saveError?: Error
) {
const { errors, warnings } = getValidationFailures(saveError);
@ -105,7 +105,7 @@ function selectSettings<T extends ModelBaseSetting>(
warnings: getFailures(warnings, key),
};
if (pendingChanges.hasOwnProperty(key)) {
if (pendingChanges?.hasOwnProperty(key)) {
setting.previousValue = setting.value;
setting.value = pendingChanges[key];
setting.pending = true;
@ -126,7 +126,7 @@ function selectSettings<T extends ModelBaseSetting>(
f
);
if ('fields' in pendingChanges) {
if (pendingChanges && 'fields' in pendingChanges) {
const pendingChangesFields = pendingChanges.fields as Record<
string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any

@ -0,0 +1,21 @@
import ModelBase from 'App/ModelBase';
import Field from './Field';
export interface AutoTaggingSpecification {
id: number;
name: string;
implementation: string;
implementationName: string;
negate: boolean;
required: boolean;
fields: Field[];
}
interface AutoTagging extends ModelBase {
name: string;
removeTagsAutomatically: boolean;
tags: number[];
specifications: AutoTaggingSpecification[];
}
export default AutoTagging;

@ -0,0 +1,17 @@
import ModelBase from 'App/ModelBase';
interface DelayProfile extends ModelBase {
name: string;
enableUsenet: boolean;
enableTorrent: boolean;
preferredProtocol: string;
usenetDelay: number;
torrentDelay: number;
bypassIfHighestQuality: boolean;
bypassIfAboveCustomFormatScore: boolean;
minimumCustomFormatScore: number;
order: number;
tags: number[];
}
export default DelayProfile;
Loading…
Cancel
Save