FeedsTab: migrate to Functional Component

This commit is contained in:
Jesse Chan
2021-01-10 22:16:36 +08:00
parent 6dd8a0096a
commit d21173cf87
10 changed files with 611 additions and 644 deletions
+6 -16
View File
@@ -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
@@ -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<Rule, 'tags' | 'feedIDs'> {
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<RuleFormData>;
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<string, string | undefined>),
);
return {
...memo,
[validatedField]: validateField(validatedField, formData[validatedField]),
};
}, {} as Record<string, string | undefined>);
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}>
<ModalFormSectionHeader>
@@ -158,7 +165,7 @@ const DownloadRulesTab: FC = () => {
{Object.keys(errors).reduce((memo: ReactNodeArray, key) => {
if (errors[key as ValidatedField] != null) {
memo.push(
<FormRow key={key}>
<FormRow key={`error-${key}`}>
<FormError>{intl.formatMessage({id: errors?.[key as ValidatedField]})}</FormError>
</FormRow>,
);
@@ -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<Feed, 'interval' | 'label' | 'url'>;
intervalMultipliers: Readonly<Array<{message: string; value: number}>>;
isSubmitting: boolean;
onCancel: () => void;
}
const FeedForm: FC<FeedFormProps> = ({
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 (
<FormRowGroup>
<FormRow>
<Textbox
id="label"
label={intl.formatMessage({
id: 'feeds.label',
})}
placeholder={intl.formatMessage({
id: 'feeds.label',
})}
defaultValue={currentFeed?.label ?? defaultFeed.label}
/>
<Textbox
id="interval"
label={intl.formatMessage({
id: 'feeds.select.interval',
})}
placeholder={intl.formatMessage({
id: 'feeds.interval',
})}
defaultValue={defaultIntervalTextValue}
width="one-eighth"
/>
<Select labelOffset defaultID={defaultIntervalMultiplier} id="intervalMultiplier" width="one-eighth">
{intervalMultipliers.map((interval) => (
<SelectItem key={interval.value} id={interval.value}>
{intl.formatMessage({id: interval.message})}
</SelectItem>
))}
</Select>
</FormRow>
<FormRow>
<Textbox
id="url"
label={intl.formatMessage({
id: 'feeds.url',
})}
placeholder={intl.formatMessage({id: 'feeds.url'})}
defaultValue={currentFeed?.url ?? defaultFeed?.url}
/>
<Button labelOffset onClick={onCancel}>
<FormattedMessage id="button.cancel" />
</Button>
<Button labelOffset type="submit" isLoading={isSubmitting}>
<FormattedMessage id="button.save.feed" />
</Button>
</FormRow>
</FormRowGroup>
);
};
export default FeedForm;
@@ -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<FeedItemsProps> = observer(({selectedFeedID}: FeedItemsProps) => {
const {items} = FeedStore;
const itemElements: ReactNodeArray = [];
if (selectedFeedID) {
const titleOccurrences: Record<string, number> = {};
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(
<li className="interactive-list__item interactive-list__item--stacked-content feed-list__feed" key={title}>
<div className="interactive-list__label feed-list__feed-label">{title}</div>
<Checkbox id={`${index}`} />
</li>,
);
});
}
return (
<FormRow>
{itemElements.length === 0 ? (
<ul className="interactive-list">
<li className="interactive-list__item">
<div className="interactive-list__label">
<FormattedMessage id="feeds.no.items.matching" />
</div>
</li>
</ul>
) : (
<ul className="interactive-list feed-list">{itemElements}</ul>
)}
</FormRow>
);
});
export default FeedItems;
@@ -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<Form>(null);
const [selectedFeedID, setSelectedFeedID] = useState<string | null>(null);
const {feeds} = FeedStore;
if (selectedFeedID != null) {
if (!feeds.some((feed) => feed._id === selectedFeedID)) {
setSelectedFeedID(null);
}
}
return (
<Form
className="inverse"
onChange={({event, formData}) => {
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}>
<ModalFormSectionHeader>
<FormattedMessage id="feeds.browse.feeds" />
</ModalFormSectionHeader>
<FormRow>
<Select
disabled={!feeds.length}
grow={false}
id="feedID"
label={intl.formatMessage({
id: 'feeds.select.feed',
})}
width="three-eighths">
{!feeds.length
? [
<SelectItem key="empty" id="placeholder" placeholder>
<em>
<FormattedMessage id="feeds.no.feeds.available" />
</em>
</SelectItem>,
]
: feeds.reduce(
(feedOptions, feed) => {
if (feed._id == null) {
return feedOptions;
}
return feedOptions.concat(
<SelectItem key={feed._id} id={feed._id}>
{feed.label}
</SelectItem>,
);
},
[
<SelectItem key="select-feed" id="placeholder" placeholder>
<em>
<FormattedMessage id="feeds.select.feed" />
</em>
</SelectItem>,
],
)}
</Select>
{selectedFeedID && (
<Textbox
id="search"
label={intl.formatMessage({
id: 'feeds.search.term',
})}
placeholder={intl.formatMessage({
id: 'feeds.search',
})}
/>
)}
{selectedFeedID && (
<Button key="button" type="submit" labelOffset>
<FormattedMessage id="button.download" />
</Button>
)}
</FormRow>
{selectedFeedID && <FeedItems selectedFeedID={selectedFeedID} />}
</Form>
);
});
export default FeedItemsForm;
@@ -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<Array<{message: string; value: number}>>;
onSelect: (feed: Feed) => void;
onRemove: (feed: Feed) => void;
}
const FeedList: FC<FeedListProps> = observer(
({currentFeed, intervalMultipliers, onSelect, onRemove}: FeedListProps) => {
const {feeds} = FeedStore;
const intl = useIntl();
if (feeds.length === 0) {
return (
<ul className="interactive-list">
<li className="interactive-list__item">
<FormattedMessage id="feeds.no.feeds.defined" />
</li>
</ul>
);
}
return (
<ul className="interactive-list feed-list">
{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 (
<li
className="interactive-list__item interactive-list__item--stacked-content feed-list__feed"
key={feed._id}>
<div className="interactive-list__label">
<ul className="interactive-list__detail-list">
<li
className="interactive-list__detail-list__item
interactive-list__detail--primary">
{feed.label}
</li>
<li
className="interactive-list__detail-list__item
interactive-list__detail-list__item--overflow
interactive-list__detail interactive-list__detail--secondary">
<FormattedMessage id="feeds.match.count" values={{count: matchedCount}} />
</li>
{feed === currentFeed && (
<li
className="interactive-list__detail-list__item
interactive-list__detail--primary">
Modifying
</li>
)}
</ul>
<ul className="interactive-list__detail-list">
<li
className="interactive-list__detail-list__item
interactive-list__detail interactive-list__detail--tertiary">
{`${intervalText} ${intl.formatMessage({id: intervalMultiplierMessage})}`}
</li>
<li
className="interactive-list__detail-list__item
interactive-list__detail-list__item--overflow
interactive-list__detail interactive-list__detail--tertiary">
<a href={feed.url} rel="noopener noreferrer" target="_blank">
{feed.url}
</a>
</li>
</ul>
</div>
<span
className="interactive-list__icon interactive-list__icon--action"
onClick={() => {
onSelect(feed);
}}>
<Edit />
</span>
<span
className="interactive-list__icon interactive-list__icon--action interactive-list__icon--action--warning"
onClick={() => {
onRemove(feed);
}}>
<Close />
</span>
</li>
);
})}
</ul>
);
},
);
export default FeedList;
@@ -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<WrappedComponentProps> {
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 (
<Modal
heading={this.props.intl.formatMessage({
id: 'feeds.tabs.heading',
})}
orientation="horizontal"
size="large"
tabs={tabs}
/>
);
}
}
return (
<Modal
heading={intl.formatMessage({
id: 'feeds.tabs.heading',
})}
orientation="horizontal"
size="large"
tabs={tabs}
/>
);
};
export default injectIntl(FeedsModal);
export default FeedsModal;
@@ -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<Feed> | 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<WrappedComponentProps, FeedsTabStates> {
formRef: Form | null = null;
const FeedsTab: FC = () => {
const formRef = useRef<Form>(null);
const intl = useIntl();
const [currentFeed, setCurrentFeed] = useState<Feed | null>(null);
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [isEditing, setIsEditing] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState<boolean>(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<Feed, 'url' | 'label' | 'interval'> | null {
if (this.formRef == null) {
return null;
}
const formData = this.formRef.getFormData() as Partial<FeedFormData>;
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) => (
<SelectItem key={interval.value} id={interval.value}>
{this.props.intl.formatMessage(interval.message)}
</SelectItem>
));
}
getModifyFeedForm(feed: Partial<Feed>) {
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 (
<FormRowGroup>
<FormRow>
<Textbox
id="label"
label={this.props.intl.formatMessage(MESSAGES.label)}
placeholder={this.props.intl.formatMessage(MESSAGES.label)}
defaultValue={feed.label}
/>
<Textbox
id="interval"
label={this.props.intl.formatMessage({
id: 'feeds.select.interval',
})}
placeholder={this.props.intl.formatMessage(MESSAGES.interval)}
defaultValue={defaultIntervalTextValue}
width="one-eighth"
/>
<Select labelOffset defaultID={defaultIntervalMultiplier} id="intervalMultiplier" width="one-eighth">
{this.getIntervalSelectOptions()}
</Select>
</FormRow>
<FormRow>
<Textbox
id="url"
label={this.props.intl.formatMessage({
id: 'feeds.url',
})}
placeholder={this.props.intl.formatMessage(MESSAGES.url)}
defaultValue={feed.url}
/>
<Button labelOffset onClick={() => this.setState({currentlyEditingFeed: null})}>
<FormattedMessage id="button.cancel" />
</Button>
<Button labelOffset type="submit">
<FormattedMessage id="button.save.feed" />
</Button>
</FormRow>
</FormRowGroup>
);
}
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 (
<li className="interactive-list__item interactive-list__item--stacked-content feed-list__feed" key={feed._id}>
<div className="interactive-list__label">
<ul className="interactive-list__detail-list">
<li
className="interactive-list__detail-list__item
interactive-list__detail--primary">
{feed.label}
</li>
<li
className="interactive-list__detail-list__item
interactive-list__detail-list__item--overflow
interactive-list__detail interactive-list__detail--secondary">
<FormattedMessage id="feeds.match.count" values={{count: matchedCount}} />
</li>
{feed === this.state.currentlyEditingFeed && (
<li
className="interactive-list__detail-list__item
interactive-list__detail--primary">
Modifying
</li>
)}
</ul>
<ul className="interactive-list__detail-list">
<li
className="interactive-list__detail-list__item
interactive-list__detail interactive-list__detail--tertiary">
{`${intervalText} ${intl.formatMessage(intervalMultiplierMessage)}`}
</li>
<li
className="interactive-list__detail-list__item
interactive-list__detail-list__item--overflow
interactive-list__detail interactive-list__detail--tertiary">
<a href={feed.url} rel="noopener noreferrer" target="_blank">
{feed.url}
</a>
</li>
</ul>
</div>
<span
className="interactive-list__icon interactive-list__icon--action"
onClick={() => this.handleModifyFeedClick(feed)}>
<Edit />
</span>
<span
className="interactive-list__icon interactive-list__icon--action interactive-list__icon--action--warning"
onClick={() => this.handleRemoveFeedClick(feed)}>
<Close />
</span>
</li>
);
}
getFeedAddForm(errors: React.ReactNode) {
return (
return (
<div>
<Form
className="inverse"
onChange={this.handleFormChange}
onSubmit={this.handleFormSubmit}
ref={(ref) => {
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<string, string | undefined>);
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}>
<ModalFormSectionHeader>
<FormattedMessage id="feeds.existing.feeds" />
</ModalFormSectionHeader>
{errors}
{Object.keys(errors).reduce((memo: ReactNodeArray, key) => {
if (errors[key as ValidatedField] != null) {
memo.push(
<FormRow key={`error-${key}`}>
<FormError>{intl.formatMessage({id: errors?.[key as ValidatedField]})}</FormError>
</FormRow>,
);
}
return memo;
}, [])}
<FormRow>
<FormRowItem>{this.getFeedsList()}</FormRowItem>
<FormRowItem>
<FeedList
currentFeed={currentFeed}
intervalMultipliers={INTERVAL_MULTIPLIERS}
onSelect={(feed) => {
setCurrentFeed(feed);
setIsEditing(true);
}}
onRemove={(feed) => {
if (feed === currentFeed) {
if (isEditing) {
setErrors({});
setIsEditing(false);
}
setCurrentFeed(null);
}
if (feed._id != null) {
FeedActions.removeFeedMonitor(feed._id);
}
}}
/>
</FormRowItem>
</FormRow>
{this.state.currentlyEditingFeed ? (
this.getModifyFeedForm(this.state.currentlyEditingFeed)
{isEditing ? (
<FeedForm
currentFeed={currentFeed}
defaultFeed={defaultFeed}
intervalMultipliers={INTERVAL_MULTIPLIERS}
isSubmitting={isSubmitting}
onCancel={() => {
setErrors({});
setIsEditing(false);
setCurrentFeed(null);
}}
/>
) : (
<FormRow>
<FormRowItem width="auto">{null}</FormRowItem>
<Button onClick={() => this.handleAddFeedClick()}>
<FormRowItem width="auto" />
<Button
onClick={() => {
setIsEditing(true);
}}>
<FormattedMessage id="button.new" />
</Button>
</FormRow>
)}
</Form>
);
}
<FeedItemsForm />
</div>
);
};
getFeedsList() {
const {feeds} = FeedStore;
if (feeds.length === 0) {
return (
<ul className="interactive-list">
<li className="interactive-list__item">
<FormattedMessage id="feeds.no.feeds.defined" />
</li>
</ul>
);
}
const feedsList = feeds.map((feed) => this.getFeedsListItem(feed));
return <ul className="interactive-list feed-list">{feedsList}</ul>;
}
getFeedItemsForm() {
const {feeds, items} = FeedStore;
const itemElements: React.ReactNodeArray = [];
if (this.state.selectedFeedID) {
const titleOccurrences: Record<string, number> = {};
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(
<li className="interactive-list__item interactive-list__item--stacked-content feed-list__feed" key={title}>
<div className="interactive-list__label feed-list__feed-label">{title}</div>
<Checkbox id={`${index}`} />
</li>,
);
});
}
return (
<Form
className="inverse"
onChange={this.handleBrowseFeedChange}
onSubmit={this.handleBrowseFeedSubmit}
ref={(ref) => {
this.manualAddingFormRef = ref;
}}>
<ModalFormSectionHeader>
<FormattedMessage id="feeds.browse.feeds" />
</ModalFormSectionHeader>
<FormRow>
<Select
disabled={!feeds.length}
grow={false}
id="feedID"
label={this.props.intl.formatMessage({
id: 'feeds.select.feed',
})}
width="three-eighths">
{!feeds.length
? [
<SelectItem key="empty" id="placeholder" placeholder>
<em>
<FormattedMessage id="feeds.no.feeds.available" />
</em>
</SelectItem>,
]
: feeds.reduce(
(feedOptions, feed) => {
if (feed._id == null) {
return feedOptions;
}
return feedOptions.concat(
<SelectItem key={feed._id} id={feed._id}>
{feed.label}
</SelectItem>,
);
},
[
<SelectItem key="select-feed" id="placeholder" placeholder>
<em>
<FormattedMessage id="feeds.select.feed" />
</em>
</SelectItem>,
],
)}
</Select>
{this.renderSearchField()}
{this.renderDownloadButton()}
</FormRow>
{this.state.selectedFeedID ? (
<FormRow>
{itemElements.length === 0 ? (
<ul className="interactive-list">
<li className="interactive-list__item">
<div className="interactive-list__label">
<FormattedMessage id="feeds.no.items.matching" />
</div>
</li>
</ul>
) : (
<ul className="interactive-list feed-list">{itemElements}</ul>
)}
</FormRow>
) : null}
</Form>
);
}
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<HTMLFormElement>;
formData: Record<string, unknown>;
}) => {
const validatedField = (event.target as HTMLInputElement).name as ValidatedFields;
const feedForm = formData as Partial<Feed>;
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<HTMLFormElement>;
formData: Record<string, unknown>;
}) => {
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 (
<Textbox
id="search"
label={this.props.intl.formatMessage({
id: 'feeds.search.term',
})}
placeholder={this.props.intl.formatMessage(MESSAGES.search)}
/>
);
};
renderDownloadButton = () => {
const {selectedFeedID} = this.state;
if (selectedFeedID == null) return null;
return (
<Button key="button" type="submit" labelOffset>
<FormattedMessage id="button.download" />
</Button>
);
};
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 (
<FormRow key={errorID}>
<FormError>{this.state.errors[errorID]}</FormError>
</FormRow>
);
});
}
return (
<div>
{this.getFeedAddForm(errors)}
{this.getFeedItemsForm()}
</div>
);
}
}
export default injectIntl(FeedsTab);
export default FeedsTab;
@@ -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;
+3 -2
View File
@@ -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);