diff --git a/client/src/javascript/components/general/form-elements/FilesystemBrowserTextbox.tsx b/client/src/javascript/components/general/form-elements/FilesystemBrowserTextbox.tsx index dd2c0f6f..9c7e2dfb 100644 --- a/client/src/javascript/components/general/form-elements/FilesystemBrowserTextbox.tsx +++ b/client/src/javascript/components/general/form-elements/FilesystemBrowserTextbox.tsx @@ -1,6 +1,7 @@ import debounce from 'lodash/debounce'; import {FormattedMessage, useIntl} from 'react-intl'; -import {FC, ReactNode, useEffect, useRef, useState} from 'react'; +import {forwardRef, MutableRefObject, ReactNode, useEffect, useRef, useState} from 'react'; +import {useEnsuredForwardedRef} from 'react-use'; import {Checkbox, ContextMenu, FormElementAddon, FormRow, FormRowGroup, Portal, Textbox} from '../../../ui'; import FilesystemBrowser from '../filesystem/FilesystemBrowser'; @@ -17,129 +18,137 @@ interface FilesystemBrowserTextboxProps { onChange?: (destination: string) => void; } -const FilesystemBrowserTextbox: FC = ({ - id, - label, - selectable, - suggested, - showBasePathToggle, - showCompletedToggle, - onChange, -}: FilesystemBrowserTextboxProps) => { - const [destination, setDestination] = useState( - suggested ?? - SettingStore.floodSettings.torrentDestinations?.[''] ?? - SettingStore.clientSettings?.directoryDefault ?? - '', - ); - const [isDirectoryListOpen, setIsDirectoryListOpen] = useState(false); - - const formRowRef = useRef(null); - const textboxRef = useRef(null); - - const intl = useIntl(); - - useEffect(() => { - const closeDirectoryList = (): void => { - setIsDirectoryListOpen(false); - }; - - const handleDocumentClick = (e: Event): void => { - if (!formRowRef.current?.contains((e.target as unknown) as Node)) { - closeDirectoryList(); - } - }; - - document.addEventListener('click', handleDocumentClick); - window.addEventListener('resize', closeDirectoryList); - - return () => { - document.removeEventListener('click', handleDocumentClick); - window.removeEventListener('resize', closeDirectoryList); - }; - }, []); - - const toggles: React.ReactNodeArray = []; - if (showBasePathToggle) { - toggles.push( - - - , +const FilesystemBrowserTextbox = forwardRef( + ( + { + id, + label, + selectable, + suggested, + showBasePathToggle, + showCompletedToggle, + onChange, + }: FilesystemBrowserTextboxProps, + ref, + ) => { + const [destination, setDestination] = useState( + suggested ?? + SettingStore.floodSettings.torrentDestinations?.[''] ?? + SettingStore.clientSettings?.directoryDefault ?? + '', ); - } - if (showCompletedToggle) { - toggles.push( - - - , + const [isDirectoryListOpen, setIsDirectoryListOpen] = useState(false); + + const formRowRef = useRef(null); + const textboxRef = useEnsuredForwardedRef(ref as MutableRefObject); + + const intl = useIntl(); + + useEffect(() => { + const closeDirectoryList = (): void => { + setIsDirectoryListOpen(false); + }; + + const handleDocumentClick = (e: Event): void => { + if (!formRowRef.current?.contains((e.target as unknown) as Node)) { + closeDirectoryList(); + } + }; + + document.addEventListener('click', handleDocumentClick); + window.addEventListener('resize', closeDirectoryList); + + return () => { + document.removeEventListener('click', handleDocumentClick); + window.removeEventListener('resize', closeDirectoryList); + }; + }, []); + + const toggles: React.ReactNodeArray = []; + if (showBasePathToggle) { + toggles.push( + + + , + ); + } + if (showCompletedToggle) { + toggles.push( + + + , + ); + } + + return ( + + + { + if (textboxRef.current == null) { + return; + } + + const newDestination = textboxRef.current.value; + + if (onChange) { + onChange(newDestination); + } + + setDestination(newDestination); + }, + 100, + {leading: true}, + )} + onClick={(event) => event.nativeEvent.stopImmediatePropagation()} + placeholder={intl.formatMessage({ + id: 'torrents.add.destination.placeholder', + })} + ref={textboxRef}> + { + if (textboxRef.current != null) { + setDestination(textboxRef.current.value); + } + setIsDirectoryListOpen(!isDirectoryListOpen); + }}> + + + + event.nativeEvent.stopImmediatePropagation()} + overlayProps={{isInteractive: false}} + padding={false} + triggerRef={textboxRef}> + { + if (textboxRef.current != null) { + textboxRef.current.value = newDestination; + } + + setDestination(newDestination); + setIsDirectoryListOpen(isDirectory); + }} + /> + + + + + {toggles.length > 0 ? {toggles} : null} + ); - } - - return ( - - - { - if (textboxRef.current == null) { - return; - } - - const newDestination = textboxRef.current.value; - - if (onChange) { - onChange(newDestination); - } - - setDestination(newDestination); - }, - 100, - {leading: true}, - )} - onClick={(event) => event.nativeEvent.stopImmediatePropagation()} - placeholder={intl.formatMessage({ - id: 'torrents.add.destination.placeholder', - })} - ref={textboxRef}> - { - setIsDirectoryListOpen(!isDirectoryListOpen); - }}> - - - - event.nativeEvent.stopImmediatePropagation()} - overlayProps={{isInteractive: false}} - padding={false} - triggerRef={textboxRef}> - { - if (textboxRef.current != null) { - textboxRef.current.value = newDestination; - } - - setDestination(newDestination); - setIsDirectoryListOpen(isDirectory); - }} - /> - - - - - {toggles.length > 0 ? {toggles} : null} - - ); -}; + }, +); FilesystemBrowserTextbox.defaultProps = { label: undefined, diff --git a/client/src/javascript/components/general/form-elements/TagSelect.tsx b/client/src/javascript/components/general/form-elements/TagSelect.tsx index b7c360bd..f6f34bb6 100644 --- a/client/src/javascript/components/general/form-elements/TagSelect.tsx +++ b/client/src/javascript/components/general/form-elements/TagSelect.tsx @@ -15,9 +15,10 @@ interface TagSelectProps { label?: ReactNode; defaultValue?: TorrentProperties['tags']; placeholder?: string; + onTagSelected?: (tags: TorrentProperties['tags']) => void; } -const TagSelect: FC = ({defaultValue, placeholder, id, label}: TagSelectProps) => { +const TagSelect: FC = ({defaultValue, placeholder, id, label, onTagSelected}: TagSelectProps) => { const [isOpen, setIsOpen] = useState(false); const [selectedTags, setSelectedTags] = useState>(defaultValue ?? []); const formRowRef = useRef(null); @@ -51,7 +52,10 @@ const TagSelect: FC = ({defaultValue, placeholder, id, label}: T if (textboxRef.current != null) { textboxRef.current.value = selectedTags.join(); } - }, [selectedTags]); + if (onTagSelected) { + onTagSelected(selectedTags); + } + }, [selectedTags, onTagSelected]); return ( @@ -62,6 +66,24 @@ const TagSelect: FC = ({defaultValue, placeholder, id, label}: T id={id || 'tags'} addonPlacement="after" defaultValue={defaultValue} + onChange={() => { + if (textboxRef.current != null) { + let selectedTagsArray = textboxRef.current.value + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); + + if (textboxRef.current.value.trimEnd().endsWith(',')) { + // Ensures that the trailing ',' does not get removed automatically. + selectedTagsArray.push(''); + + // Deduplicate + selectedTagsArray = [...new Set(selectedTagsArray)]; + } + + setSelectedTags(selectedTagsArray); + } + }} placeholder={placeholder} ref={textboxRef}> = ({defaultValue, placeholder, id, label}: T overlayProps={{isInteractive: false}} ref={menuRef} triggerRef={textboxRef}> - {Object.keys(TorrentFilterStore.taxonomy.tagCounts).reduce((accumulator: ReactNodeArray, tag) => { - if (tag === '') { - return accumulator; - } + {[...new Set([...Object.keys(TorrentFilterStore.taxonomy.tagCounts), ...selectedTags])].reduce( + (accumulator: ReactNodeArray, tag) => { + if (tag === '') { + return accumulator; + } - accumulator.push( - { - if (tag === 'untagged') { - setSelectedTags([]); - } else if (selectedTags.includes(tag)) { - setSelectedTags(selectedTags.filter((key) => key !== tag)); - } else { - setSelectedTags([...selectedTags, tag]); - } - }}> - {tag === 'untagged' ? : tag} - , - ); - return accumulator; - }, [])} + accumulator.push( + { + if (tag === 'untagged') { + setSelectedTags([]); + } else if (selectedTags.includes(tag)) { + setSelectedTags(selectedTags.filter((key) => key !== tag && key !== '')); + } else { + setSelectedTags([...selectedTags.filter((key) => key !== ''), tag]); + } + }}> + {tag === 'untagged' ? : tag} + , + ); + return accumulator; + }, + [], + )} @@ -119,6 +144,7 @@ TagSelect.defaultProps = { label: undefined, defaultValue: undefined, placeholder: undefined, + onTagSelected: undefined, }; export default TagSelect; diff --git a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByFile.tsx b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByFile.tsx index ca8146c6..1588653a 100644 --- a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByFile.tsx +++ b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByFile.tsx @@ -6,6 +6,7 @@ import FileDropzone from '../../general/form-elements/FileDropzone'; import FilesystemBrowserTextbox from '../../general/form-elements/FilesystemBrowserTextbox'; import {Form, FormRow} from '../../../ui'; import {saveAddTorrentsUserPreferences} from '../../../util/userPreferences'; +import SettingStore from '../../../stores/SettingStore'; import TagSelect from '../../general/form-elements/TagSelect'; import TorrentActions from '../../../actions/TorrentActions'; import UIStore from '../../../stores/UIStore'; @@ -23,9 +24,11 @@ interface AddTorrentsByFileFormData { const AddTorrentsByFile: FC = () => { const filesRef = useRef([]); const formRef = useRef
(null); - const intl = useIntl(); + const textboxRef = useRef(null); const [isAddingTorrents, setIsAddingTorrents] = useState(false); + const intl = useIntl(); + return ( @@ -35,23 +38,33 @@ const AddTorrentsByFile: FC = () => { }} /> - { + if (textboxRef.current != null) { + const suggestedPath = SettingStore.floodSettings.torrentDestinations?.[tags[0]]; + if (typeof suggestedPath === 'string' && textboxRef.current != null) { + textboxRef.current.value = suggestedPath; + textboxRef.current.dispatchEvent(new Event('input', {bubbles: true})); + } + } + }} /> + { if (formRef.current == null) { @@ -73,7 +86,7 @@ const AddTorrentsByFile: FC = () => { return; } - const tagsArray = tags != null ? tags.split(',') : undefined; + const tagsArray = tags != null ? tags.split(',').filter((tag) => tag.length > 0) : undefined; TorrentActions.addTorrentsByFiles({ files: filesData as [string, ...string[]], diff --git a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByURL.tsx b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByURL.tsx index 77790082..a790e8d2 100644 --- a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByURL.tsx +++ b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByURL.tsx @@ -5,6 +5,7 @@ import AddTorrentsActions from './AddTorrentsActions'; import FilesystemBrowserTextbox from '../../general/form-elements/FilesystemBrowserTextbox'; import {Form, FormRow} from '../../../ui'; import {saveAddTorrentsUserPreferences} from '../../../util/userPreferences'; +import SettingStore from '../../../stores/SettingStore'; import TagSelect from '../../general/form-elements/TagSelect'; import TextboxRepeater, {getTextArray} from '../../general/form-elements/TextboxRepeater'; import TorrentActions from '../../../actions/TorrentActions'; @@ -24,9 +25,11 @@ type AddTorrentsByURLFormData = { const AddTorrentsByURL: FC = () => { const formRef = useRef(null); - const intl = useIntl(); + const textboxRef = useRef(null); const [isAddingTorrents, setIsAddingTorrents] = useState(false); + const intl = useIntl(); + return ( { id: 'torrents.add.cookies.input.placeholder', })} /> - { + if (textboxRef.current != null) { + const suggestedPath = SettingStore.floodSettings.torrentDestinations?.[tags[0]]; + if (typeof suggestedPath === 'string' && textboxRef.current != null) { + textboxRef.current.value = suggestedPath; + textboxRef.current.dispatchEvent(new Event('input', {bubbles: true})); + } + } + }} /> + { if (formRef.current == null) { @@ -93,7 +106,7 @@ const AddTorrentsByURL: FC = () => { } : undefined; - const tags = formData.tags != null ? formData.tags.split(',') : undefined; + const tags = formData.tags != null ? formData.tags.split(',').filter((tag) => tag.length > 0) : undefined; TorrentActions.addTorrentsByUrls({ urls: urls as [string, ...string[]],