From 4dc9944bda10fb1ef16c5c0381ccf792cbe996ae Mon Sep 17 00:00:00 2001 From: Jesse Chan Date: Tue, 5 Jan 2021 12:08:26 +0800 Subject: [PATCH] DownloadRulesTab: migrate to Functional Component --- client/src/javascript/actions/FeedActions.ts | 11 +- .../modals/feeds-modal/DownloadRuleForm.tsx | 160 ++++ .../modals/feeds-modal/DownloadRuleList.tsx | 123 +++ .../modals/feeds-modal/DownloadRulesTab.tsx | 749 +++++------------- client/src/javascript/util/validators.ts | 2 +- 5 files changed, 479 insertions(+), 566 deletions(-) create mode 100644 client/src/javascript/components/modals/feeds-modal/DownloadRuleForm.tsx create mode 100644 client/src/javascript/components/modals/feeds-modal/DownloadRuleList.tsx diff --git a/client/src/javascript/actions/FeedActions.ts b/client/src/javascript/actions/FeedActions.ts index 9911c5bb..9c7b7fc1 100644 --- a/client/src/javascript/actions/FeedActions.ts +++ b/client/src/javascript/actions/FeedActions.ts @@ -38,14 +38,9 @@ const FeedActions = { axios .put(`${baseURI}api/feed-monitor/rules`, options) .then((json) => json.data) - .then( - () => { - FeedActions.fetchFeedMonitors(); - }, - () => { - // do nothing. - }, - ), + .then(() => { + FeedActions.fetchFeedMonitors(); + }), fetchFeedMonitors: () => axios diff --git a/client/src/javascript/components/modals/feeds-modal/DownloadRuleForm.tsx b/client/src/javascript/components/modals/feeds-modal/DownloadRuleForm.tsx new file mode 100644 index 00000000..bd05d438 --- /dev/null +++ b/client/src/javascript/components/modals/feeds-modal/DownloadRuleForm.tsx @@ -0,0 +1,160 @@ +import {FC} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import type {AddRuleOptions} from '@shared/types/api/feed-monitor'; + +import { + Button, + Checkbox, + FormElementAddon, + FormRow, + FormRowGroup, + FormRowItem, + Select, + SelectItem, + Textbox, +} from '../../../ui'; +import Checkmark from '../../icons/Checkmark'; +import FeedStore from '../../../stores/FeedStore'; +import FilesystemBrowserTextbox from '../../general/form-elements/FilesystemBrowserTextbox'; +import TagSelect from '../../general/form-elements/TagSelect'; + +interface DownloadRuleFormProps { + rule: AddRuleOptions; + isSubmitting: boolean; + isPatternMatched: boolean; + onCancel: () => void; +} + +const DownloadRuleForm: FC = ({ + rule, + isSubmitting, + isPatternMatched, + onCancel, +}: DownloadRuleFormProps) => { + const {feeds} = FeedStore; + const intl = useIntl(); + + return ( + + + + + + + + + + + + {isPatternMatched && ( + + + + )} + + + + + + + + + +
+ + + + + +
+
+ ); +}; + +export default DownloadRuleForm; diff --git a/client/src/javascript/components/modals/feeds-modal/DownloadRuleList.tsx b/client/src/javascript/components/modals/feeds-modal/DownloadRuleList.tsx new file mode 100644 index 00000000..f1a36ce0 --- /dev/null +++ b/client/src/javascript/components/modals/feeds-modal/DownloadRuleList.tsx @@ -0,0 +1,123 @@ +import {FC} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {observer} from 'mobx-react'; + +import {Rule} from '@shared/types/Feed'; + +import Close from '../../icons/Close'; +import Edit from '../../icons/Edit'; +import FeedStore from '../../../stores/FeedStore'; + +interface DownloadRuleListProps { + currentRule: Rule | null; + onSelect: (rule: Rule) => void; + onRemove: (rule: Rule) => void; +} + +const DownloadRuleList: FC = observer( + ({currentRule, onSelect, onRemove}: DownloadRuleListProps) => { + const {rules} = FeedStore; + + if (rules.length === 0) { + return ( +
    +
  • + +
  • +
+ ); + } + + return ( +
    + {rules.map((rule) => { + const matchedCount = rule.count || 0; + let excludeNode = null; + let tags = null; + + if (rule.exclude) { + excludeNode = ( +
  • + + {': '} + {rule.exclude} +
  • + ); + } + + if (rule.tags && rule.tags.length > 0) { + const tagNodes = rule.tags.map((tag) => ( + + {tag} + + )); + + tags = ( +
  • + + {': '} + {tagNodes} +
  • + ); + } + + return ( +
  • +
    +
      +
    • + {rule.label} +
    • +
    • + +
    • + {rule === currentRule && ( +
    • + Modifying +
    • + )} +
    +
      +
    • + + {': '} + {rule.match} +
    • +
      + {excludeNode} + {tags} +
    +
    + onSelect(rule)}> + + + onRemove(rule)}> + + +
  • + ); + })} +
+ ); + }, +); + +export default DownloadRuleList; diff --git a/client/src/javascript/components/modals/feeds-modal/DownloadRulesTab.tsx b/client/src/javascript/components/modals/feeds-modal/DownloadRulesTab.tsx index 449701b8..c9cd8d73 100644 --- a/client/src/javascript/components/modals/feeds-modal/DownloadRulesTab.tsx +++ b/client/src/javascript/components/modals/feeds-modal/DownloadRulesTab.tsx @@ -1,85 +1,19 @@ -import {defineMessages, FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; -import {observer} from 'mobx-react'; -import throttle from 'lodash/throttle'; -import * as React from 'react'; +import {FC, ReactNodeArray, useRef, useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; import type {AddRuleOptions} from '@shared/types/api/feed-monitor'; import type {Rule} from '@shared/types/Feed'; -import { - Button, - Checkbox, - Form, - FormElementAddon, - FormError, - FormRow, - FormRowGroup, - FormRowItem, - Select, - SelectItem, - Textbox, -} from '../../../ui'; -import Checkmark from '../../icons/Checkmark'; -import Close from '../../icons/Close'; -import Edit from '../../icons/Edit'; +import {Button, Form, FormError, FormRow, FormRowItem} from '../../../ui'; +import DownloadRuleForm from './DownloadRuleForm'; import FeedActions from '../../../actions/FeedActions'; -import FeedStore from '../../../stores/FeedStore'; -import FilesystemBrowserTextbox from '../../general/form-elements/FilesystemBrowserTextbox'; import ModalFormSectionHeader from '../ModalFormSectionHeader'; -import TagSelect from '../../general/form-elements/TagSelect'; import * as validators from '../../../util/validators'; +import DownloadRuleList from './DownloadRuleList'; -type ValidatedFields = 'destination' | 'feedID' | 'label' | 'match' | 'exclude'; - -interface RuleFormData extends Omit { - check: string; - feedID: string; - tags: string; -} - -interface DownloadRulesTabStates { - errors?: { - [field in ValidatedFields]?: string; - }; - isSubmitting: boolean; - isFormChanged: boolean; - currentlyEditingRule: Partial | null; - doesPatternMatchTest: boolean; -} - -const MESSAGES = defineMessages({ - mustSpecifyDestination: { - id: 'feeds.validation.must.specify.destination', - }, - mustSelectFeed: { - id: 'feeds.validation.must.select.feed', - }, - mustSpecifyLabel: { - id: 'feeds.validation.must.specify.label', - }, - invalidRegularExpression: { - id: 'feeds.validation.invalid.regular.expression', - }, - url: { - id: 'feeds.url', - }, - label: { - id: 'feeds.label', - }, - regEx: { - id: 'feeds.regEx', - }, - tags: { - id: 'feeds.tags', - }, - check: { - id: 'feeds.check', - }, -}); - -const defaultRule = { +const initialRule: AddRuleOptions = { label: '', - feedIDs: [''], + feedIDs: [], match: '', exclude: '', tags: [], @@ -87,494 +21,195 @@ const defaultRule = { startOnLoad: false, }; -@observer -class DownloadRulesTab extends React.Component { - formRef: Form | null = null; - - validatedFields = { - destination: { - isValid: validators.isNotEmpty, - error: this.props.intl.formatMessage(MESSAGES.mustSpecifyDestination), - }, - feedID: { - isValid: validators.isNotEmpty, - error: this.props.intl.formatMessage(MESSAGES.mustSelectFeed), - }, - label: { - isValid: validators.isNotEmpty, - error: this.props.intl.formatMessage(MESSAGES.mustSpecifyLabel), - }, - match: { - isValid: (value: string) => validators.isNotEmpty(value) && validators.isRegExValid(value), - error: this.props.intl.formatMessage(MESSAGES.invalidRegularExpression), - }, - exclude: { - isValid: (value: string) => { - if (validators.isNotEmpty(value)) { - return validators.isRegExValid(value); - } - - return true; - }, - error: this.props.intl.formatMessage(MESSAGES.invalidRegularExpression), - }, - }; - - 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: {}, - isSubmitting: false, - isFormChanged: false, - currentlyEditingRule: null, - doesPatternMatchTest: false, - }; - } - - getAmendedFormData(): AddRuleOptions | null { - if (this.formRef == null) { - return null; - } - - const formData = this.formRef.getFormData() as Partial; - if (formData == null) { - return null; - } - - const feedIDs = [formData.feedID || '']; - - delete formData.feedID; - delete formData.check; - - return { - ...defaultRule, - ...formData, - feedIDs, - ...(formData.tags != null - ? { - tags: formData.tags.split(','), - } - : { - tags: [], - }), - }; - } - - getModifyRuleForm(rule: Partial) { - const {doesPatternMatchTest, currentlyEditingRule} = this.state; - const {feeds} = FeedStore; - - return ( - - - - - - - - - - - - {doesPatternMatchTest && ( - - - - )} - - - - - - - - - -
- - - - - -
-
- ); - } - - getRulesListItem(rule: Rule) { - const matchedCount = rule.count || 0; - let excludeNode = null; - let tags = null; - - if (rule.exclude) { - excludeNode = ( -
  • - - {': '} - {rule.exclude} -
  • - ); - } - - if (rule.tags && rule.tags.length > 0) { - const tagNodes = rule.tags.map((tag) => ( - - {tag} - - )); - - tags = ( -
  • - - {': '} - {tagNodes} -
  • - ); - } - - return ( -
  • -
    -
      -
    • - {rule.label} -
    • -
    • - -
    • - {rule === this.state.currentlyEditingRule && ( -
    • - Modifying -
    • - )} -
    -
      -
    • - - {': '} - {rule.match} -
    • -
      - {excludeNode} - {tags} -
    -
    - this.handleModifyRuleClick(rule)}> - - - this.handleRemoveRuleClick(rule)}> - - -
  • - ); - } - - getRulesList(): [React.ReactNode, React.ReactNode] { - const {rules} = FeedStore; - - if (rules.length === 0) { - return [ -
      -
    • - -
    • -
    , - null, - ]; - } - - const rulesList = rules.map((rule) => this.getRulesListItem(rule)); - - if (this.state.currentlyEditingRule == null || this.state.currentlyEditingRule === defaultRule) { - return [ -
      - {rulesList} -
    , - null, - ]; - } - - const editingRuleIndex = rules.indexOf(this.state.currentlyEditingRule as Rule); - - return [ -
      - {rulesList.slice(0, editingRuleIndex + 1)} -
    , -
      - {rulesList.slice(editingRuleIndex + 1)} -
    , - ]; - } - - handleFormChange = ({ - event, - formData, - }: { - event: Event | React.FormEvent; - formData: Record; - }) => { - const validatedField = (event.target as HTMLInputElement).name as ValidatedFields; - const ruleFormData = formData as Partial; - this.checkFieldValidity(validatedField, ruleFormData[validatedField]); - this.checkMatchingPattern( - ruleFormData.match != null ? ruleFormData.match : defaultRule.match, - ruleFormData.exclude != null ? ruleFormData.exclude : defaultRule.exclude, - ruleFormData.check != null ? ruleFormData.check : '', - ); - this.setState({isFormChanged: true}); - }; - - handleFormSubmit = async () => { - const {errors, isValid} = this.validateForm(); - - this.setState({isSubmitting: true}); - - if (!isValid) { - this.setState({errors}); - } else { - const currentRule = this.state.currentlyEditingRule; - const formData = this.getAmendedFormData(); - - if (formData != null && this.state.isFormChanged) { - if (currentRule !== null && currentRule !== defaultRule && currentRule._id != null) { - await FeedActions.removeFeedMonitor(currentRule._id); - } - await FeedActions.addRule(formData); +const validatedFields = { + destination: { + isValid: validators.isNotEmpty, + error: 'feeds.validation.must.specify.destination', + }, + feedID: { + isValid: (value: string | undefined) => validators.isNotEmpty(value) && value !== 'placeholder', + error: 'feeds.validation.must.select.feed', + }, + label: { + isValid: validators.isNotEmpty, + error: 'feeds.validation.must.specify.label', + }, + match: { + isValid: (value: string | undefined) => validators.isNotEmpty(value) && validators.isRegExValid(value), + error: 'feeds.validation.invalid.regular.expression', + }, + exclude: { + isValid: (value: string | undefined) => { + if (validators.isNotEmpty(value)) { + return validators.isRegExValid(value); } - if (this.formRef != null) { - this.formRef.resetForm(); - } + return true; + }, + error: 'feeds.validation.invalid.regular.expression', + }, +} as const; - this.setState({currentlyEditingRule: null}); - } +type ValidatedField = keyof typeof validatedFields; - this.setState({isSubmitting: false}); - }; +const validateField = (validatedField: ValidatedField, value: string | undefined): string | undefined => + validatedFields[validatedField]?.isValid(value) ? undefined : validatedFields[validatedField]?.error; - handleAddRuleClick = () => { - this.setState({currentlyEditingRule: defaultRule}); - }; - - handleRemoveRuleClick(rule: Rule) { - if (rule._id != null) { - FeedActions.removeFeedMonitor(rule._id); - } - - if (rule === this.state.currentlyEditingRule) { - this.setState({currentlyEditingRule: null}); - } - } - - handleModifyRuleClick(rule: Rule) { - this.setState({currentlyEditingRule: rule}); - } - - checkMatchingPattern(match: RuleFormData['match'], exclude: RuleFormData['exclude'], check: RuleFormData['check']) { - let doesPatternMatchTest = false; - - if (validators.isNotEmpty(check) && validators.isRegExValid(match) && validators.isRegExValid(exclude)) { - const isMatched = new RegExp(match, 'gi').test(check); - const isExcluded = exclude !== '' && new RegExp(exclude, 'gi').test(check); - doesPatternMatchTest = isMatched && !isExcluded; - } - - this.setState({doesPatternMatchTest}); - } - - validateForm(): { - errors?: DownloadRulesTabStates['errors']; - isValid: boolean; - } { - const formData = this.getAmendedFormData(); - - if (formData == null) { - return {isValid: false}; - } - - const errors = Object.keys(this.validatedFields).reduce((accumulator: DownloadRulesTabStates['errors'], field) => { - const fieldName = field as ValidatedFields; - const fieldValue = fieldName === 'feedID' ? formData.feedIDs[0] : formData[fieldName]; - - if (!this.validatedFields[fieldName].isValid(fieldValue) && accumulator != null) { - accumulator[fieldName] = this.validatedFields[fieldName].error; - } - - return accumulator; - }, {}); - - if (errors == null) { - return {isValid: true}; - } - - return {errors, isValid: !Object.keys(errors).length}; - } - - 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]} - - ); - }); - } - - const [listBeforeEditingRule, listAfterEditingRule] = this.getRulesList(); - - return ( -
    { - this.formRef = ref; - }}> - - - - {errors} - {listAfterEditingRule == null ? ( - - {listBeforeEditingRule} - - ) : ( - - {listBeforeEditingRule} - {this.getModifyRuleForm(this.state.currentlyEditingRule as Partial)} - {listAfterEditingRule} - - )} - {this.state.currentlyEditingRule && listAfterEditingRule == null ? ( - this.getModifyRuleForm(this.state.currentlyEditingRule) - ) : ( - -
    - -
    - )} -
    - ); - } +interface RuleFormData extends Omit { + check: string; + feedID: string; + tags: string; } -export default injectIntl(DownloadRulesTab); +const DownloadRulesTab: FC = () => { + const formRef = useRef
    (null); + const intl = useIntl(); + + const [currentRule, setCurrentRule] = useState(null); + const [errors, setErrors] = useState>({}); + const [isEditing, setIsEditing] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isFormChanged, setIsFormChanged] = useState(false); + const [isPatternMatched, setIsPatternMatched] = useState(false); + + return ( + { + const validatedField = (event.target as HTMLInputElement).name as ValidatedField; + const ruleFormData = formData as Partial; + + setErrors({ + ...errors, + [validatedField]: validateField(validatedField, ruleFormData[validatedField]), + }); + + setIsPatternMatched( + (() => { + const {check, match = '', exclude = ''} = ruleFormData; + + if (validators.isNotEmpty(check) && validators.isRegExValid(match) && validators.isRegExValid(exclude)) { + const isMatched = new RegExp(match, 'gi').test(check); + const isExcluded = exclude !== '' && new RegExp(exclude, 'gi').test(check); + return isMatched && !isExcluded; + } + + return false; + })(), + ); + + setIsFormChanged(true); + }} + onSubmit={async () => { + if (formRef.current == null) { + return; + } + + const formData = formRef.current.getFormData() as Partial; + + setIsSubmitting(true); + setErrors( + Object.keys(validatedFields).reduce((memo, key) => { + const validatedField = key as ValidatedField; + + return { + ...memo, + [validatedField]: validateField(validatedField, formData[validatedField]), + }; + }, {} as Record), + ); + + const isFormValid = Object.keys(errors).every((key) => errors[key] === undefined); + + if (isFormChanged && isFormValid) { + if (currentRule?._id != null) { + await FeedActions.removeFeedMonitor(currentRule._id); + } + + await FeedActions.addRule({ + label: formData.label ?? initialRule.label, + feedIDs: [formData.feedID ?? ''], + field: formData.field, + match: formData.match ?? initialRule.match, + exclude: formData.exclude ?? initialRule.exclude, + destination: formData.destination ?? initialRule.destination, + tags: formData.tags?.split(',') ?? initialRule.tags, + startOnLoad: formData.startOnLoad ?? initialRule.startOnLoad, + isBasePath: formData.isBasePath ?? false, + }).then( + () => { + formRef.current?.resetForm(); + setCurrentRule(null); + setErrors({}); + setIsEditing(false); + }, + (err: Error) => { + setErrors({backend: err.message}); + }, + ); + } + + setIsSubmitting(false); + }} + ref={formRef}> + + + + {Object.keys(errors).reduce((memo: ReactNodeArray, key) => { + if (errors[key as ValidatedField] != null) { + memo.push( + + {intl.formatMessage({id: errors?.[key as ValidatedField]})} + , + ); + } + + return memo; + }, [])} + + + { + setCurrentRule(rule); + setIsEditing(true); + }} + onRemove={(rule) => { + if (rule._id != null) { + FeedActions.removeFeedMonitor(rule._id); + } + + if (rule === currentRule) { + setCurrentRule(null); + setIsEditing(false); + } + }} + /> + + + {isEditing ? ( + { + setCurrentRule(null); + setIsEditing(false); + }} + /> + ) : ( + +
    + +
    + )} + + ); +}; + +export default DownloadRulesTab; diff --git a/client/src/javascript/util/validators.ts b/client/src/javascript/util/validators.ts index dccb82d9..ceca0f92 100644 --- a/client/src/javascript/util/validators.ts +++ b/client/src/javascript/util/validators.ts @@ -1,6 +1,6 @@ import {url as matchURL} from '@shared/util/regEx'; -export const isNotEmpty = (value: string) => value != null && value !== ''; +export const isNotEmpty = (value: string | undefined): value is string => value != null && value !== ''; export const isRegExValid = (regExToCheck: string) => { try {