diff --git a/client/src/javascript/actions/FeedActions.ts b/client/src/javascript/actions/FeedActions.ts index 9c7b7fc1..cd810f76 100644 --- a/client/src/javascript/actions/FeedActions.ts +++ b/client/src/javascript/actions/FeedActions.ts @@ -12,27 +12,17 @@ const FeedActions = { axios .put(`${baseURI}api/feed-monitor/feeds`, options) .then((json) => json.data) - .then( - () => { - FeedActions.fetchFeedMonitors(); - }, - () => { - // do nothing. - }, - ), + .then(() => { + FeedActions.fetchFeedMonitors(); + }), modifyFeed: (id: string, options: ModifyFeedOptions) => axios .patch(`${baseURI}api/feed-monitor/feeds/${id}`, options) .then((json) => json.data) - .then( - () => { - FeedActions.fetchFeedMonitors(); - }, - () => { - // do nothing. - }, - ), + .then(() => { + FeedActions.fetchFeedMonitors(); + }), addRule: (options: AddRuleOptions) => axios diff --git a/client/src/javascript/components/modals/feeds-modal/DownloadRulesTab.tsx b/client/src/javascript/components/modals/feeds-modal/DownloadRulesTab.tsx index c9cd8d73..f4cf97ce 100644 --- a/client/src/javascript/components/modals/feeds-modal/DownloadRulesTab.tsx +++ b/client/src/javascript/components/modals/feeds-modal/DownloadRulesTab.tsx @@ -7,8 +7,8 @@ import type {Rule} from '@shared/types/Feed'; import {Button, Form, FormError, FormRow, FormRowItem} from '../../../ui'; import DownloadRuleForm from './DownloadRuleForm'; import FeedActions from '../../../actions/FeedActions'; +import {isNotEmpty, isRegExValid} from '../../../util/validators'; import ModalFormSectionHeader from '../ModalFormSectionHeader'; -import * as validators from '../../../util/validators'; import DownloadRuleList from './DownloadRuleList'; const initialRule: AddRuleOptions = { @@ -23,25 +23,25 @@ const initialRule: AddRuleOptions = { const validatedFields = { destination: { - isValid: validators.isNotEmpty, + isValid: isNotEmpty, error: 'feeds.validation.must.specify.destination', }, feedID: { - isValid: (value: string | undefined) => validators.isNotEmpty(value) && value !== 'placeholder', + isValid: (value: string | undefined) => isNotEmpty(value) && value !== 'placeholder', error: 'feeds.validation.must.select.feed', }, label: { - isValid: validators.isNotEmpty, + isValid: isNotEmpty, error: 'feeds.validation.must.specify.label', }, match: { - isValid: (value: string | undefined) => validators.isNotEmpty(value) && validators.isRegExValid(value), + isValid: (value: string | undefined) => isNotEmpty(value) && isRegExValid(value), error: 'feeds.validation.invalid.regular.expression', }, exclude: { isValid: (value: string | undefined) => { - if (validators.isNotEmpty(value)) { - return validators.isRegExValid(value); + if (isNotEmpty(value)) { + return isRegExValid(value); } return true; @@ -55,10 +55,17 @@ type ValidatedField = keyof typeof validatedFields; const validateField = (validatedField: ValidatedField, value: string | undefined): string | undefined => validatedFields[validatedField]?.isValid(value) ? undefined : validatedFields[validatedField]?.error; -interface RuleFormData extends Omit { +interface RuleFormData { check: string; + exclude: string; + destination: string; + field: string; feedID: string; + label: string; + match: string; tags: string; + isBasePath: boolean; + startOnLoad: boolean; } const DownloadRulesTab: FC = () => { @@ -88,7 +95,7 @@ const DownloadRulesTab: FC = () => { (() => { const {check, match = '', exclude = ''} = ruleFormData; - if (validators.isNotEmpty(check) && validators.isRegExValid(match) && validators.isRegExValid(exclude)) { + if (isNotEmpty(check) && isRegExValid(match) && isRegExValid(exclude)) { const isMatched = new RegExp(match, 'gi').test(check); const isExcluded = exclude !== '' && new RegExp(exclude, 'gi').test(check); return isMatched && !isExcluded; @@ -107,21 +114,21 @@ const DownloadRulesTab: FC = () => { const formData = formRef.current.getFormData() as Partial; - setIsSubmitting(true); - setErrors( - Object.keys(validatedFields).reduce((memo, key) => { - const validatedField = key as ValidatedField; + const currentErrors = Object.keys(validatedFields).reduce((memo, key) => { + const validatedField = key as ValidatedField; - return { - ...memo, - [validatedField]: validateField(validatedField, formData[validatedField]), - }; - }, {} as Record), - ); + return { + ...memo, + [validatedField]: validateField(validatedField, formData[validatedField]), + }; + }, {} as Record); + setErrors(currentErrors); - const isFormValid = Object.keys(errors).every((key) => errors[key] === undefined); + const isFormValid = Object.keys(currentErrors).every((key) => currentErrors[key] === undefined); if (isFormChanged && isFormValid) { + setIsSubmitting(true); + if (currentRule?._id != null) { await FeedActions.removeFeedMonitor(currentRule._id); } @@ -139,17 +146,17 @@ const DownloadRulesTab: FC = () => { }).then( () => { formRef.current?.resetForm(); - setCurrentRule(null); setErrors({}); + setCurrentRule(null); setIsEditing(false); }, - (err: Error) => { - setErrors({backend: err.message}); + () => { + setErrors({backend: 'general.error.unknown'}); }, ); - } - setIsSubmitting(false); + setIsSubmitting(false); + } }} ref={formRef}> @@ -158,7 +165,7 @@ const DownloadRulesTab: FC = () => { {Object.keys(errors).reduce((memo: ReactNodeArray, key) => { if (errors[key as ValidatedField] != null) { memo.push( - + {intl.formatMessage({id: errors?.[key as ValidatedField]})} , ); diff --git a/client/src/javascript/components/modals/feeds-modal/FeedForm.tsx b/client/src/javascript/components/modals/feeds-modal/FeedForm.tsx new file mode 100644 index 00000000..93b61cd0 --- /dev/null +++ b/client/src/javascript/components/modals/feeds-modal/FeedForm.tsx @@ -0,0 +1,90 @@ +import {FC} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import type {Feed} from '@shared/types/Feed'; + +import {Button, FormRow, FormRowGroup, Select, SelectItem, Textbox} from '../../../ui'; + +interface FeedFormProps { + currentFeed: Feed | null; + defaultFeed: Pick; + intervalMultipliers: Readonly>; + isSubmitting: boolean; + onCancel: () => void; +} + +const FeedForm: FC = ({ + currentFeed, + defaultFeed, + intervalMultipliers, + isSubmitting, + onCancel, +}: FeedFormProps) => { + const intl = useIntl(); + const feedInterval = currentFeed?.interval ?? defaultFeed.interval; + + let defaultIntervalTextValue = feedInterval; + let defaultIntervalMultiplier = 1; + + intervalMultipliers.forEach((interval) => { + const intervalMultiplier = interval.value; + + if (feedInterval % intervalMultiplier === 0) { + defaultIntervalTextValue = feedInterval / intervalMultiplier; + defaultIntervalMultiplier = intervalMultiplier; + } + }); + + return ( + + + + + + + + + + + + + ); +}; + +export default FeedForm; diff --git a/client/src/javascript/components/modals/feeds-modal/FeedItems.tsx b/client/src/javascript/components/modals/feeds-modal/FeedItems.tsx new file mode 100644 index 00000000..14468180 --- /dev/null +++ b/client/src/javascript/components/modals/feeds-modal/FeedItems.tsx @@ -0,0 +1,56 @@ +import {FC, ReactNodeArray} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {observer} from 'mobx-react'; + +import {Checkbox, FormRow} from '../../../ui'; + +import FeedStore from '../../../stores/FeedStore'; + +interface FeedItemsProps { + selectedFeedID: string; +} + +const FeedItems: FC = observer(({selectedFeedID}: FeedItemsProps) => { + const {items} = FeedStore; + + const itemElements: ReactNodeArray = []; + if (selectedFeedID) { + const titleOccurrences: Record = {}; + items.forEach((item, index) => { + let {title} = item; + const occurrence = titleOccurrences[title]; + + if (occurrence == null) { + titleOccurrences[title] = 2; + } else { + title = `${title} #${occurrence}`; + titleOccurrences[title] += 1; + } + + itemElements.push( +
  • +
    {title}
    + +
  • , + ); + }); + } + + return ( + + {itemElements.length === 0 ? ( +
      +
    • +
      + +
      +
    • +
    + ) : ( +
      {itemElements}
    + )} +
    + ); +}); + +export default FeedItems; diff --git a/client/src/javascript/components/modals/feeds-modal/FeedItemsForm.tsx b/client/src/javascript/components/modals/feeds-modal/FeedItemsForm.tsx new file mode 100644 index 00000000..662c700d --- /dev/null +++ b/client/src/javascript/components/modals/feeds-modal/FeedItemsForm.tsx @@ -0,0 +1,120 @@ +import {FC, useRef, useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {observer} from 'mobx-react'; + +import {Button, Form, FormRow, Select, SelectItem, Textbox} from '../../../ui'; + +import FeedActions from '../../../actions/FeedActions'; +import FeedItems from './FeedItems'; +import FeedStore from '../../../stores/FeedStore'; +import ModalFormSectionHeader from '../ModalFormSectionHeader'; +import UIActions from '../../../actions/UIActions'; + +const FeedItemsForm: FC = observer(() => { + const intl = useIntl(); + const manualAddingFormRef = useRef
    (null); + const [selectedFeedID, setSelectedFeedID] = useState(null); + + const {feeds} = FeedStore; + + if (selectedFeedID != null) { + if (!feeds.some((feed) => feed._id === selectedFeedID)) { + setSelectedFeedID(null); + } + } + + return ( + { + const feedBrowseForm = formData as {feedID: string; search: string}; + if ((event.target as HTMLInputElement).type !== 'checkbox') { + setSelectedFeedID(feedBrowseForm.feedID); + FeedActions.fetchItems({ + id: feedBrowseForm.feedID, + search: feedBrowseForm.search, + }); + } + }} + onSubmit={() => { + if (manualAddingFormRef.current == null) { + return; + } + + const formData = manualAddingFormRef.current.getFormData(); + + // TODO: Properly handle array of array of URLs + const torrentsToDownload = FeedStore.items + .filter((_item, index) => formData[index]) + .map((item, index) => ({id: index, value: item.urls[0]})); + + UIActions.displayModal({ + id: 'add-torrents', + initialURLs: torrentsToDownload, + }); + }} + ref={manualAddingFormRef}> + + + + + + {selectedFeedID && ( + + )} + {selectedFeedID && ( + + )} + + {selectedFeedID && } + + ); +}); + +export default FeedItemsForm; diff --git a/client/src/javascript/components/modals/feeds-modal/FeedList.tsx b/client/src/javascript/components/modals/feeds-modal/FeedList.tsx new file mode 100644 index 00000000..e0796bd3 --- /dev/null +++ b/client/src/javascript/components/modals/feeds-modal/FeedList.tsx @@ -0,0 +1,111 @@ +import {FC} from 'react'; +import {observer} from 'mobx-react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import type {Feed} from '@shared/types/Feed'; + +import Close from '../../icons/Close'; +import Edit from '../../icons/Edit'; +import FeedStore from '../../../stores/FeedStore'; + +interface FeedListProps { + currentFeed: Feed | null; + intervalMultipliers: Readonly>; + onSelect: (feed: Feed) => void; + onRemove: (feed: Feed) => void; +} + +const FeedList: FC = observer( + ({currentFeed, intervalMultipliers, onSelect, onRemove}: FeedListProps) => { + const {feeds} = FeedStore; + const intl = useIntl(); + + if (feeds.length === 0) { + return ( +
      +
    • + +
    • +
    + ); + } + + return ( +
      + {feeds.map((feed) => { + const matchedCount = feed.count || 0; + + let intervalText = `${feed.interval}`; + let intervalMultiplierMessage = intervalMultipliers[0].message; + + intervalMultipliers.forEach((interval) => { + if (feed.interval % interval.value === 0) { + intervalText = `${feed.interval / interval.value}`; + intervalMultiplierMessage = interval.message; + } + }); + + return ( +
    • +
      +
        +
      • + {feed.label} +
      • +
      • + +
      • + {feed === currentFeed && ( +
      • + Modifying +
      • + )} +
      +
        +
      • + {`${intervalText} ${intl.formatMessage({id: intervalMultiplierMessage})}`} +
      • +
      • + + {feed.url} + +
      • +
      +
      + { + onSelect(feed); + }}> + + + { + onRemove(feed); + }}> + + +
    • + ); + })} +
    + ); + }, +); + +export default FeedList; diff --git a/client/src/javascript/components/modals/feeds-modal/FeedsModal.tsx b/client/src/javascript/components/modals/feeds-modal/FeedsModal.tsx index 1ff62603..841e0e29 100644 --- a/client/src/javascript/components/modals/feeds-modal/FeedsModal.tsx +++ b/client/src/javascript/components/modals/feeds-modal/FeedsModal.tsx @@ -1,50 +1,43 @@ -import {Component} from 'react'; -import {injectIntl, WrappedComponentProps} from 'react-intl'; +import {FC, useEffect} from 'react'; +import {useIntl} from 'react-intl'; import DownloadRulesTab from './DownloadRulesTab'; import FeedActions from '../../../actions/FeedActions'; -import FeedStore from '../../../stores/FeedStore'; import FeedsTab from './FeedsTab'; import Modal from '../Modal'; -class FeedsModal extends Component { - componentDidMount() { +const FeedsModal: FC = () => { + const intl = useIntl(); + + useEffect(() => { FeedActions.fetchFeedMonitors(); - } + }, []); - render() { - const tabs = { - feeds: { - content: FeedsTab, - props: { - feedStore: FeedStore, - }, - label: this.props.intl.formatMessage({ - id: 'feeds.tabs.feeds', - }), - }, - downloadRules: { - content: DownloadRulesTab, - props: { - feedStore: FeedStore, - }, - label: this.props.intl.formatMessage({ - id: 'feeds.tabs.download.rules', - }), - }, - }; + const tabs = { + feeds: { + content: FeedsTab, + label: intl.formatMessage({ + id: 'feeds.tabs.feeds', + }), + }, + downloadRules: { + content: DownloadRulesTab, + label: intl.formatMessage({ + id: 'feeds.tabs.download.rules', + }), + }, + }; - return ( - - ); - } -} + return ( + + ); +}; -export default injectIntl(FeedsModal); +export default FeedsModal; diff --git a/client/src/javascript/components/modals/feeds-modal/FeedsTab.tsx b/client/src/javascript/components/modals/feeds-modal/FeedsTab.tsx index 2160bd28..d9f4a86b 100644 --- a/client/src/javascript/components/modals/feeds-modal/FeedsTab.tsx +++ b/client/src/javascript/components/modals/feeds-modal/FeedsTab.tsx @@ -1,605 +1,204 @@ -import {defineMessages, FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; -import {observer} from 'mobx-react'; -import * as React from 'react'; -import throttle from 'lodash/throttle'; +import {FC, ReactNodeArray, useRef, useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import type {AddFeedOptions} from '@shared/types/api/feed-monitor'; import type {Feed} from '@shared/types/Feed'; -import { - Button, - Checkbox, - Form, - FormError, - FormRow, - FormRowGroup, - FormRowItem, - Select, - SelectItem, - Textbox, -} from '../../../ui'; -import Close from '../../icons/Close'; -import Edit from '../../icons/Edit'; +import {Button, Form, FormError, FormRow, FormRowItem} from '../../../ui'; import FeedActions from '../../../actions/FeedActions'; -import FeedStore from '../../../stores/FeedStore'; +import FeedForm from './FeedForm'; +import FeedItemsForm from './FeedItemsForm'; +import FeedList from './FeedList'; +import {isNotEmpty, isPositiveInteger, isURLValid} from '../../../util/validators'; import ModalFormSectionHeader from '../ModalFormSectionHeader'; -import UIActions from '../../../actions/UIActions'; -import * as validators from '../../../util/validators'; -type ValidatedFields = 'url' | 'label' | 'interval'; - -interface FeedFormData extends Feed { - url: string; - label: string; - interval: number; - intervalMultiplier: number; -} - -interface FeedsTabStates { - errors?: { - [field in ValidatedFields]?: string; - }; - currentlyEditingFeed: Partial | null; - selectedFeedID: string | null; -} - -const MESSAGES = defineMessages({ - mustSpecifyURL: { - id: 'feeds.validation.must.specify.valid.feed.url', - }, - mustSpecifyLabel: { - id: 'feeds.validation.must.specify.label', - }, - intervalNotPositive: { - id: 'feeds.validation.interval.not.positive', - }, - min: { - id: 'feeds.time.min', - }, - hr: { - id: 'feeds.time.hr', - }, - day: { - id: 'feeds.time.day', - }, +const validatedFields = { url: { - id: 'feeds.url', + isValid: isURLValid, + error: 'feeds.validation.must.specify.valid.feed.url', }, label: { - id: 'feeds.label', + isValid: isNotEmpty, + error: 'feeds.validation.must.specify.label', }, interval: { - id: 'feeds.interval', + isValid: isPositiveInteger, + error: 'feeds.validation.interval.not.positive', }, - tags: { - id: 'feeds.tags', - }, - search: { - id: 'feeds.search', - }, -}); +} as const; + +type ValidatedField = keyof typeof validatedFields; + +const validateField = (validatedField: ValidatedField, value: string | undefined): string | undefined => + validatedFields[validatedField]?.isValid(value) ? undefined : validatedFields[validatedField]?.error; + +interface FeedFormData { + url: string; + label: string; + interval: string; + intervalMultiplier: string; +} const INTERVAL_MULTIPLIERS = [ { - message: MESSAGES.min, + message: 'feeds.time.min', value: 1, }, { - message: MESSAGES.hr, + message: 'feeds.time.hr', value: 60, }, { - message: MESSAGES.day, + message: 'feeds.time.day', value: 1440, }, ] as const; -const defaultFeed = { +const defaultFeed: AddFeedOptions = { label: '', interval: 5, url: '', }; -@observer -class FeedsTab extends React.Component { - formRef: Form | null = null; +const FeedsTab: FC = () => { + const formRef = useRef
    (null); + const intl = useIntl(); + const [currentFeed, setCurrentFeed] = useState(null); + const [errors, setErrors] = useState>({}); + const [isEditing, setIsEditing] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); - manualAddingFormRef: Form | null = null; - - validatedFields = { - url: { - isValid: validators.isURLValid, - error: this.props.intl.formatMessage(MESSAGES.mustSpecifyURL), - }, - label: { - isValid: validators.isNotEmpty, - error: this.props.intl.formatMessage(MESSAGES.mustSpecifyLabel), - }, - interval: { - isValid: validators.isPositiveInteger, - error: this.props.intl.formatMessage(MESSAGES.intervalNotPositive), - }, - }; - - checkFieldValidity = throttle((fieldName: ValidatedFields, fieldValue) => { - const {errors} = this.state; - - if (errors == null) { - return; - } - - if (errors[fieldName] && this.validatedFields[fieldName].isValid(fieldValue)) { - delete errors[fieldName]; - this.setState({errors}); - } - }, 150); - - constructor(props: WrappedComponentProps) { - super(props); - - this.state = { - errors: {}, - currentlyEditingFeed: null, - selectedFeedID: null, - }; - } - - getAmendedFormData(): Pick | null { - if (this.formRef == null) { - return null; - } - - const formData = this.formRef.getFormData() as Partial; - - const {url, label} = formData; - if (url == null || label == null) { - return null; - } - - let {interval} = defaultFeed; - if (formData.interval != null && formData.intervalMultiplier != null) { - interval = formData.interval * formData.intervalMultiplier; - } - - return {url, label, interval}; - } - - getIntervalSelectOptions() { - return INTERVAL_MULTIPLIERS.map((interval) => ( - - {this.props.intl.formatMessage(interval.message)} - - )); - } - - getModifyFeedForm(feed: Partial) { - const feedInterval = feed.interval || defaultFeed.interval; - - let defaultIntervalTextValue = feedInterval; - let defaultIntervalMultiplier = 1; - - INTERVAL_MULTIPLIERS.forEach((interval) => { - const intervalMultiplier = interval.value; - - if (feedInterval % intervalMultiplier === 0) { - defaultIntervalTextValue = feedInterval / intervalMultiplier; - defaultIntervalMultiplier = intervalMultiplier; - } - }); - - return ( - - - - - - - - - - - - - ); - } - - getFeedsListItem(feed: Feed) { - const {intl} = this.props; - const matchedCount = feed.count || 0; - - let intervalText = `${feed.interval}`; - let intervalMultiplierMessage = INTERVAL_MULTIPLIERS[0].message; - - INTERVAL_MULTIPLIERS.forEach((interval) => { - if (feed.interval % interval.value === 0) { - intervalText = `${feed.interval / interval.value}`; - intervalMultiplierMessage = interval.message; - } - }); - - return ( -
  • -
    -
      -
    • - {feed.label} -
    • -
    • - -
    • - {feed === this.state.currentlyEditingFeed && ( -
    • - Modifying -
    • - )} -
    -
      -
    • - {`${intervalText} ${intl.formatMessage(intervalMultiplierMessage)}`} -
    • -
    • - - {feed.url} - -
    • -
    -
    - this.handleModifyFeedClick(feed)}> - - - this.handleRemoveFeedClick(feed)}> - - -
  • - ); - } - - getFeedAddForm(errors: React.ReactNode) { - return ( + return ( +
    { - this.formRef = ref; - }}> + onChange={({event, formData}) => { + const validatedField = (event.target as HTMLInputElement).name as ValidatedField; + const feedForm = (formData as unknown) as FeedFormData; + + setErrors({ + ...errors, + [validatedField]: validateField(validatedField, feedForm[validatedField]), + }); + }} + onSubmit={async () => { + const feedForm = (formRef.current?.getFormData() as unknown) as FeedFormData; + if (formRef.current == null || feedForm == null) { + return; + } + + setIsSubmitting(true); + + const currentErrors = Object.keys(validatedFields).reduce((memo, key) => { + const validatedField = key as ValidatedField; + + return { + ...memo, + [validatedField]: validateField(validatedField, feedForm[validatedField]), + }; + }, {} as Record); + setErrors(currentErrors); + + const isFormValid = Object.keys(currentErrors).every((key) => currentErrors[key] === undefined); + + if (isFormValid) { + const feed: AddFeedOptions = { + label: feedForm.label, + url: feedForm.url, + interval: Number(feedForm.interval) * Number(feedForm.intervalMultiplier), + }; + + let success = true; + try { + if (currentFeed === null) { + await FeedActions.addFeed(feed); + } else if (currentFeed?._id != null) { + await FeedActions.modifyFeed(currentFeed._id, feed); + } + } catch { + success = false; + } + + if (success) { + formRef.current.resetForm(); + setErrors({}); + setCurrentFeed(null); + setIsEditing(false); + } else { + setErrors({backend: 'general.error.unknown'}); + } + } + + setIsSubmitting(false); + }} + ref={formRef}> - {errors} + {Object.keys(errors).reduce((memo: ReactNodeArray, key) => { + if (errors[key as ValidatedField] != null) { + memo.push( + + {intl.formatMessage({id: errors?.[key as ValidatedField]})} + , + ); + } + + return memo; + }, [])} - {this.getFeedsList()} + + { + setCurrentFeed(feed); + setIsEditing(true); + }} + onRemove={(feed) => { + if (feed === currentFeed) { + if (isEditing) { + setErrors({}); + setIsEditing(false); + } + + setCurrentFeed(null); + } + + if (feed._id != null) { + FeedActions.removeFeedMonitor(feed._id); + } + }} + /> + - {this.state.currentlyEditingFeed ? ( - this.getModifyFeedForm(this.state.currentlyEditingFeed) + {isEditing ? ( + { + setErrors({}); + setIsEditing(false); + setCurrentFeed(null); + }} + /> ) : ( - {null} - )} - ); - } + +
    + ); +}; - getFeedsList() { - const {feeds} = FeedStore; - - if (feeds.length === 0) { - return ( -
      -
    • - -
    • -
    - ); - } - - const feedsList = feeds.map((feed) => this.getFeedsListItem(feed)); - - return
      {feedsList}
    ; - } - - getFeedItemsForm() { - const {feeds, items} = FeedStore; - - const itemElements: React.ReactNodeArray = []; - if (this.state.selectedFeedID) { - const titleOccurrences: Record = {}; - items.forEach((item, index) => { - let {title} = item; - const occurrence = titleOccurrences[title]; - - if (occurrence == null) { - titleOccurrences[title] = 2; - } else { - title = `${title} #${occurrence}`; - titleOccurrences[title] += 1; - } - - itemElements.push( -
  • -
    {title}
    - -
  • , - ); - }); - } - - return ( -
    { - this.manualAddingFormRef = ref; - }}> - - - - - - {this.renderSearchField()} - {this.renderDownloadButton()} - - {this.state.selectedFeedID ? ( - - {itemElements.length === 0 ? ( -
      -
    • -
      - -
      -
    • -
    - ) : ( -
      {itemElements}
    - )} -
    - ) : null} -
    - ); - } - - handleFormSubmit = () => { - const {errors, isValid} = this.validateForm(); - - if (!isValid) { - this.setState({errors}); - } else { - const currentFeed = this.state.currentlyEditingFeed; - const formData = this.getAmendedFormData(); - - if (formData != null) { - if (currentFeed === defaultFeed) { - FeedActions.addFeed(formData); - } else if (currentFeed?._id != null) { - FeedActions.modifyFeed(currentFeed._id, formData); - } - } - if (this.formRef != null) { - this.formRef.resetForm(); - } - this.setState({currentlyEditingFeed: null}); - } - }; - - handleFormChange = ({ - event, - formData, - }: { - event: Event | React.FormEvent; - formData: Record; - }) => { - const validatedField = (event.target as HTMLInputElement).name as ValidatedFields; - const feedForm = formData as Partial; - this.checkFieldValidity(validatedField, feedForm[validatedField]); - }; - - handleRemoveFeedClick = (feed: Feed) => { - if (feed._id != null) { - FeedActions.removeFeedMonitor(feed._id); - } - - if (feed === this.state.currentlyEditingFeed) { - this.setState({currentlyEditingFeed: null}); - } - }; - - handleAddFeedClick = () => { - this.setState({currentlyEditingFeed: defaultFeed}); - }; - - handleModifyFeedClick = (feed: Feed) => { - this.setState({currentlyEditingFeed: feed}); - }; - - handleBrowseFeedChange = (input: { - event: Event | React.FormEvent; - formData: Record; - }) => { - const feedBrowseForm = input.formData as {feedID: string; search: string}; - if ((input.event.target as HTMLInputElement).type !== 'checkbox') { - this.setState({selectedFeedID: feedBrowseForm.feedID}); - FeedActions.fetchItems({ - id: feedBrowseForm.feedID, - search: feedBrowseForm.search, - }); - } - }; - - handleBrowseFeedSubmit = () => { - if (this.manualAddingFormRef == null) { - return; - } - - const formData = this.manualAddingFormRef.getFormData(); - - // TODO: Properly handle array of array of URLs - const torrentsToDownload = FeedStore.items - .filter((_item, index) => formData[index]) - .map((item, index) => ({id: index, value: item.urls[0]})); - - UIActions.displayModal({ - id: 'add-torrents', - initialURLs: torrentsToDownload, - }); - }; - - validateForm(): {errors?: FeedsTabStates['errors']; isValid: boolean} { - if (this.formRef == null) { - return {isValid: false}; - } - - const formData = this.formRef.getFormData(); - const errors = Object.keys(this.validatedFields).reduce((memo: FeedsTabStates['errors'], field) => { - const fieldName = field as ValidatedFields; - const fieldValue = `${formData[fieldName]}`; - - return { - ...memo, - ...(!this.validatedFields[fieldName].isValid(fieldValue) && memo != null - ? {fieldName: this.validatedFields[fieldName].error} - : {}), - }; - }, {}); - - if (errors == null) { - return {isValid: true}; - } - - return {errors, isValid: !Object.keys(errors).length}; - } - - renderSearchField = () => { - const {selectedFeedID} = this.state; - - if (selectedFeedID == null) return null; - - return ( - - ); - }; - - renderDownloadButton = () => { - const {selectedFeedID} = this.state; - - if (selectedFeedID == null) return null; - - return ( - - ); - }; - - render() { - let errors = null; - if (this.state.errors != null) { - errors = Object.keys(this.state.errors).map((error) => { - const errorID = error as ValidatedFields; - if (this.state.errors?.[errorID] == null) { - return null; - } - return ( - - {this.state.errors[errorID]} - - ); - }); - } - return ( -
    - {this.getFeedAddForm(errors)} - {this.getFeedItemsForm()} -
    - ); - } -} - -export default injectIntl(FeedsTab); +export default FeedsTab; diff --git a/client/src/javascript/ui/components/FormRowItem.tsx b/client/src/javascript/ui/components/FormRowItem.tsx index 2715ede5..fad6df19 100644 --- a/client/src/javascript/ui/components/FormRowItem.tsx +++ b/client/src/javascript/ui/components/FormRowItem.tsx @@ -2,7 +2,7 @@ import classnames from 'classnames'; import * as React from 'react'; export interface FormRowItemProps { - children: React.ReactNode; + children?: React.ReactNode; className?: string; type?: string; diff --git a/client/src/javascript/util/validators.ts b/client/src/javascript/util/validators.ts index ceca0f92..dc171425 100644 --- a/client/src/javascript/util/validators.ts +++ b/client/src/javascript/util/validators.ts @@ -13,9 +13,10 @@ export const isRegExValid = (regExToCheck: string) => { return true; }; -export const isURLValid = (url: string) => url != null && url !== '' && url.match(matchURL) !== null; +export const isURLValid = (url: string | undefined): url is string => + url != null && url !== '' && url.match(matchURL) !== null; -export const isPositiveInteger = (value: number | string) => { +export const isPositiveInteger = (value: number | string | undefined) => { if (value === null || value === '') return false; const number = parseInt(`${value}`, 10);