feature: suggest tag-specific destination to textbox on tag selection

This commit is contained in:
Jesse Chan
2020-11-15 00:11:22 +08:00
parent 281f9317e1
commit d5b1e34f4a
4 changed files with 230 additions and 169 deletions
@@ -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<FilesystemBrowserTextboxProps> = ({
id,
label,
selectable,
suggested,
showBasePathToggle,
showCompletedToggle,
onChange,
}: FilesystemBrowserTextboxProps) => {
const [destination, setDestination] = useState<string>(
suggested ??
SettingStore.floodSettings.torrentDestinations?.[''] ??
SettingStore.clientSettings?.directoryDefault ??
'',
);
const [isDirectoryListOpen, setIsDirectoryListOpen] = useState<boolean>(false);
const formRowRef = useRef<HTMLDivElement>(null);
const textboxRef = useRef<HTMLInputElement>(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(
<Checkbox grow={false} id="isBasePath" key="isBasePath">
<FormattedMessage id="torrents.destination.base_path" />
</Checkbox>,
const FilesystemBrowserTextbox = forwardRef<HTMLInputElement, FilesystemBrowserTextboxProps>(
(
{
id,
label,
selectable,
suggested,
showBasePathToggle,
showCompletedToggle,
onChange,
}: FilesystemBrowserTextboxProps,
ref,
) => {
const [destination, setDestination] = useState<string>(
suggested ??
SettingStore.floodSettings.torrentDestinations?.[''] ??
SettingStore.clientSettings?.directoryDefault ??
'',
);
}
if (showCompletedToggle) {
toggles.push(
<Checkbox grow={false} id="isCompleted" key="isCompleted">
<FormattedMessage id="torrents.destination.completed" />
</Checkbox>,
const [isDirectoryListOpen, setIsDirectoryListOpen] = useState<boolean>(false);
const formRowRef = useRef<HTMLDivElement>(null);
const textboxRef = useEnsuredForwardedRef(ref as MutableRefObject<HTMLInputElement>);
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(
<Checkbox grow={false} id="isBasePath" key="isBasePath">
<FormattedMessage id="torrents.destination.base_path" />
</Checkbox>,
);
}
if (showCompletedToggle) {
toggles.push(
<Checkbox grow={false} id="isCompleted" key="isCompleted">
<FormattedMessage id="torrents.destination.completed" />
</Checkbox>,
);
}
return (
<FormRowGroup ref={formRowRef}>
<FormRow>
<Textbox
autoComplete={isDirectoryListOpen ? 'off' : undefined}
addonPlacement="after"
defaultValue={destination}
id={id}
label={label}
onChange={debounce(
() => {
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}>
<FormElementAddon
onClick={() => {
if (textboxRef.current != null) {
setDestination(textboxRef.current.value);
}
setIsDirectoryListOpen(!isDirectoryListOpen);
}}>
<Search />
</FormElementAddon>
<Portal>
<ContextMenu
isIn={isDirectoryListOpen}
onClick={(event) => event.nativeEvent.stopImmediatePropagation()}
overlayProps={{isInteractive: false}}
padding={false}
triggerRef={textboxRef}>
<FilesystemBrowser
directory={destination}
intl={intl}
selectable={selectable}
onItemSelection={(newDestination: string, isDirectory = true) => {
if (textboxRef.current != null) {
textboxRef.current.value = newDestination;
}
setDestination(newDestination);
setIsDirectoryListOpen(isDirectory);
}}
/>
</ContextMenu>
</Portal>
</Textbox>
</FormRow>
{toggles.length > 0 ? <FormRow>{toggles}</FormRow> : null}
</FormRowGroup>
);
}
return (
<FormRowGroup ref={formRowRef}>
<FormRow>
<Textbox
autoComplete={isDirectoryListOpen ? 'off' : undefined}
addonPlacement="after"
defaultValue={destination}
id={id}
label={label}
onChange={debounce(
() => {
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}>
<FormElementAddon
onClick={() => {
setIsDirectoryListOpen(!isDirectoryListOpen);
}}>
<Search />
</FormElementAddon>
<Portal>
<ContextMenu
isIn={isDirectoryListOpen}
onClick={(event) => event.nativeEvent.stopImmediatePropagation()}
overlayProps={{isInteractive: false}}
padding={false}
triggerRef={textboxRef}>
<FilesystemBrowser
directory={destination}
intl={intl}
selectable={selectable}
onItemSelection={(newDestination: string, isDirectory = true) => {
if (textboxRef.current != null) {
textboxRef.current.value = newDestination;
}
setDestination(newDestination);
setIsDirectoryListOpen(isDirectory);
}}
/>
</ContextMenu>
</Portal>
</Textbox>
</FormRow>
{toggles.length > 0 ? <FormRow>{toggles}</FormRow> : null}
</FormRowGroup>
);
};
},
);
FilesystemBrowserTextbox.defaultProps = {
label: undefined,
@@ -15,9 +15,10 @@ interface TagSelectProps {
label?: ReactNode;
defaultValue?: TorrentProperties['tags'];
placeholder?: string;
onTagSelected?: (tags: TorrentProperties['tags']) => void;
}
const TagSelect: FC<TagSelectProps> = ({defaultValue, placeholder, id, label}: TagSelectProps) => {
const TagSelect: FC<TagSelectProps> = ({defaultValue, placeholder, id, label, onTagSelected}: TagSelectProps) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [selectedTags, setSelectedTags] = useState<Array<string>>(defaultValue ?? []);
const formRowRef = useRef<HTMLDivElement>(null);
@@ -51,7 +52,10 @@ const TagSelect: FC<TagSelectProps> = ({defaultValue, placeholder, id, label}: T
if (textboxRef.current != null) {
textboxRef.current.value = selectedTags.join();
}
}, [selectedTags]);
if (onTagSelected) {
onTagSelected(selectedTags);
}
}, [selectedTags, onTagSelected]);
return (
<FormRowItem ref={formRowRef}>
@@ -62,6 +66,24 @@ const TagSelect: FC<TagSelectProps> = ({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}>
<FormElementAddon
@@ -82,30 +104,33 @@ const TagSelect: FC<TagSelectProps> = ({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(
<SelectItem
id={tag}
key={tag}
isSelected={selectedTags.includes(tag)}
onClick={() => {
if (tag === 'untagged') {
setSelectedTags([]);
} else if (selectedTags.includes(tag)) {
setSelectedTags(selectedTags.filter((key) => key !== tag));
} else {
setSelectedTags([...selectedTags, tag]);
}
}}>
{tag === 'untagged' ? <FormattedMessage id="filter.untagged" /> : tag}
</SelectItem>,
);
return accumulator;
}, [])}
accumulator.push(
<SelectItem
id={tag}
key={tag}
isSelected={selectedTags.includes(tag)}
onClick={() => {
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' ? <FormattedMessage id="filter.untagged" /> : tag}
</SelectItem>,
);
return accumulator;
},
[],
)}
</ContextMenu>
</Portal>
</Textbox>
@@ -119,6 +144,7 @@ TagSelect.defaultProps = {
label: undefined,
defaultValue: undefined,
placeholder: undefined,
onTagSelected: undefined,
};
export default TagSelect;
@@ -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<ProcessedFiles>([]);
const formRef = useRef<Form>(null);
const intl = useIntl();
const textboxRef = useRef<HTMLInputElement>(null);
const [isAddingTorrents, setIsAddingTorrents] = useState<boolean>(false);
const intl = useIntl();
return (
<Form className="inverse" ref={formRef}>
<FormRow>
@@ -35,23 +38,33 @@ const AddTorrentsByFile: FC = () => {
}}
/>
</FormRow>
<FilesystemBrowserTextbox
id="destination"
label={intl.formatMessage({
id: 'torrents.add.destination.label',
})}
selectable="directories"
showBasePathToggle
showCompletedToggle
/>
<FormRow>
<TagSelect
label={intl.formatMessage({
id: 'torrents.add.tags',
})}
id="tags"
onTagSelected={(tags) => {
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}));
}
}
}}
/>
</FormRow>
<FilesystemBrowserTextbox
id="destination"
label={intl.formatMessage({
id: 'torrents.add.destination.label',
})}
ref={textboxRef}
selectable="directories"
showBasePathToggle
showCompletedToggle
/>
<AddTorrentsActions
onAddTorrentsClick={() => {
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[]],
@@ -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<Form>(null);
const intl = useIntl();
const textboxRef = useRef<HTMLInputElement>(null);
const [isAddingTorrents, setIsAddingTorrents] = useState<boolean>(false);
const intl = useIntl();
return (
<Form className="inverse" ref={formRef}>
<TextboxRepeater
@@ -50,23 +53,33 @@ const AddTorrentsByURL: FC = () => {
id: 'torrents.add.cookies.input.placeholder',
})}
/>
<FilesystemBrowserTextbox
id="destination"
label={intl.formatMessage({
id: 'torrents.add.destination.label',
})}
selectable="directories"
showBasePathToggle
showCompletedToggle
/>
<FormRow>
<TagSelect
id="tags"
label={intl.formatMessage({
id: 'torrents.add.tags',
})}
onTagSelected={(tags) => {
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}));
}
}
}}
/>
</FormRow>
<FilesystemBrowserTextbox
id="destination"
label={intl.formatMessage({
id: 'torrents.add.destination.label',
})}
ref={textboxRef}
selectable="directories"
showBasePathToggle
showCompletedToggle
/>
<AddTorrentsActions
onAddTorrentsClick={() => {
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[]],