diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js index 076af3dca..508927d13 100644 --- a/frontend/src/Activity/Queue/QueueRow.js +++ b/frontend/src/Activity/Queue/QueueRow.js @@ -321,6 +321,8 @@ class QueueRow extends Component { downloadId={downloadId} title={title} onModalClose={this.onInteractiveImportModalClose} + showReplaceExistingFiles={true} + replaceExistingFiles={true} /> { - if (body.action === 'updated') { - this.props.dispatchUpdateItem({ - section: 'interactiveImport', - updateOnly: true, - ...body.resource - }); - } - } - handleQueue = () => { if (this.props.isQueuePopulated) { this.props.dispatchFetchQueue(); diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js b/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js index fb2fad9dd..6302df334 100644 --- a/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js +++ b/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js @@ -65,6 +65,7 @@ class SelectAlbumModalContentConnector extends Component { this.props.updateInteractiveImportItem({ id, album, + albumReleaseId: undefined, tracks: [], rejections: [] }); diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModal.js b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModal.js new file mode 100644 index 000000000..f3789d9dd --- /dev/null +++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectAlbumReleaseModalContentConnector from './SelectAlbumReleaseModalContentConnector'; + +class SelectAlbumReleaseModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectAlbumReleaseModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectAlbumReleaseModal; diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.css b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.css new file mode 100644 index 000000000..54f67bb07 --- /dev/null +++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.css @@ -0,0 +1,18 @@ +.modalBody { + composes: modalBody from '~Components/Modal/ModalBody.css'; + + display: flex; + flex: 1 1 auto; + flex-direction: column; +} + +.filterInput { + composes: input from '~Components/Form/TextInput.css'; + + flex: 0 0 auto; + margin-bottom: 20px; +} + +.scroller { + flex: 1 1 auto; +} diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.js b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.js new file mode 100644 index 000000000..5c87f982e --- /dev/null +++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.js @@ -0,0 +1,93 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import { scrollDirections } from 'Helpers/Props'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import SelectAlbumReleaseRow from './SelectAlbumReleaseRow'; +import Alert from 'Components/Alert'; +import styles from './SelectAlbumReleaseModalContent.css'; + +const columns = [ + { + name: 'album', + label: 'Album', + isVisible: true + }, + { + name: 'release', + label: 'Album Release', + isVisible: true + } +]; + +class SelectAlbumReleaseModalContent extends Component { + + // + // Render + + render() { + const { + albums, + onAlbumReleaseSelect, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + Manual Import - Select Album Release + + + + + Overrriding a release here will disable automatic release selection for that album in future. + + + + + { + albums.map((item) => { + return ( + + ); + }) + } + +
+
+ + + + +
+ ); + } +} + +SelectAlbumReleaseModalContent.propTypes = { + albums: PropTypes.arrayOf(PropTypes.object).isRequired, + onAlbumReleaseSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectAlbumReleaseModalContent; diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContentConnector.js b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContentConnector.js new file mode 100644 index 000000000..f308b03ce --- /dev/null +++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContentConnector.js @@ -0,0 +1,67 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { + updateInteractiveImportItem, + saveInteractiveImportItem +} from 'Store/Actions/interactiveImportActions'; +import SelectAlbumReleaseModalContent from './SelectAlbumReleaseModalContent'; + +function createMapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + updateInteractiveImportItem, + saveInteractiveImportItem +}; + +class SelectAlbumReleaseModalContentConnector extends Component { + + // + // Listeners + + // onSortPress = (sortKey, sortDirection) => { + // this.props.setInteractiveImportAlbumsSort({ sortKey, sortDirection }); + // } + + onAlbumReleaseSelect = (albumId, albumReleaseId) => { + const ids = this.props.importIdsByAlbum[albumId]; + + ids.forEach((id) => { + this.props.updateInteractiveImportItem({ + id, + albumReleaseId, + disableReleaseSwitching: true, + tracks: [], + rejections: [] + }); + }); + + this.props.saveInteractiveImportItem({ id: ids }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectAlbumReleaseModalContentConnector.propTypes = { + importIdsByAlbum: PropTypes.object.isRequired, + albums: PropTypes.arrayOf(PropTypes.object).isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + saveInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectAlbumReleaseModalContentConnector); diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.css b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.css new file mode 100644 index 000000000..e78f0bc19 --- /dev/null +++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.css @@ -0,0 +1,3 @@ +.albumRow { + cursor: pointer; +} diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js new file mode 100644 index 000000000..786ea0f83 --- /dev/null +++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js @@ -0,0 +1,96 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import titleCase from 'Utilities/String/titleCase'; + +class SelectAlbumReleaseRow extends Component { + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.onAlbumReleaseSelect(parseInt(name), parseInt(value)); + } + + // + // Render + + render() { + const { + id, + matchedReleaseId, + title, + disambiguation, + releases, + columns + } = this.props; + + const extendedTitle = disambiguation ? `${title} (${disambiguation})` : title; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'album') { + return ( + + {extendedTitle} + + ); + } + + if (name === 'release') { + return ( + + ({ + key: r.id, + value: `${r.title}` + + `${r.disambiguation ? ' (' : ''}${titleCase(r.disambiguation)}${r.disambiguation ? ')' : ''}` + + `, ${r.mediumCount} med, ${r.trackCount} tracks` + + `${r.country.length > 0 ? ', ' : ''}${r.country}` + + `${r.format ? ', [' : ''}${r.format}${r.format ? ']' : ''}` + + `${r.monitored ? ', Monitored' : ''}` + }))} + value={matchedReleaseId} + onChange={this.onInputChange} + /> + + ); + } + + return null; + }) + } + + + ); + } +} + +SelectAlbumReleaseRow.propTypes = { + id: PropTypes.number.isRequired, + matchedReleaseId: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + disambiguation: PropTypes.string.isRequired, + releases: PropTypes.arrayOf(PropTypes.object).isRequired, + onAlbumReleaseSelect: PropTypes.func.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default SelectAlbumReleaseRow; diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistModalContentConnector.js b/frontend/src/InteractiveImport/Artist/SelectArtistModalContentConnector.js index eda1c254e..19a6002c9 100644 --- a/frontend/src/InteractiveImport/Artist/SelectArtistModalContentConnector.js +++ b/frontend/src/InteractiveImport/Artist/SelectArtistModalContentConnector.js @@ -41,17 +41,21 @@ class SelectArtistModalContentConnector extends Component { onArtistSelect = (artistId) => { const artist = _.find(this.props.items, { id: artistId }); - this.props.ids.forEach((id) => { + const ids = this.props.ids; + + ids.forEach((id) => { this.props.updateInteractiveImportItem({ id, artist, album: undefined, + albumReleaseId: undefined, tracks: [], rejections: [] }); - this.props.saveInteractiveImportItem({ id }); }); + this.props.saveInteractiveImportItem({ id: ids }); + this.props.onModalClose(true); } diff --git a/frontend/src/InteractiveImport/Confirmation/ConfirmImportModal.js b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModal.js new file mode 100644 index 000000000..e002b2de9 --- /dev/null +++ b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import ConfirmImportModalContentConnector from './ConfirmImportModalContentConnector'; + +class ConfirmImportModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +ConfirmImportModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ConfirmImportModal; diff --git a/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContent.js b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContent.js new file mode 100644 index 000000000..5ee9203d3 --- /dev/null +++ b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContent.js @@ -0,0 +1,135 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import { kinds } from 'Helpers/Props'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Alert from 'Components/Alert'; + +function formatAlbumFiles(items, album) { + + return ( +
+ {album.title} +
    + { + _.sortBy(items, 'path').map((item) => { + return ( +
  • + {item.path} +
  • + ); + }) + } +
+
+ ); + +} + +class ConfirmImportModalContent extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + const { + items, + isFetching, + isPopulated + } = this.props; + + if (!isFetching && isPopulated && !items.length) { + this.props.onModalClose(); + this.props.onConfirmImportPress(); + } + } + + // + // Render + + render() { + const { + albums, + items, + onConfirmImportPress, + onModalClose, + isFetching, + isPopulated + } = this.props; + + // don't render if nothing to do + if (!isFetching && isPopulated && !items.length) { + return null; + } + + return ( + + + { + !isFetching && isPopulated && + + Are you sure? + + } + + + { + isFetching && + + } + + { + !isFetching && isPopulated && +
+ + You are already have files imported for the albums listed below. If you continue, the existing files will be deleted and the new files imported in their place. + + To avoid deleting existing files, press 'Cancel' and use the 'Combine with existing files' option. + + + { _.chain(items) + .groupBy('albumId') + .mapValues((value, key) => formatAlbumFiles(value, _.find(albums, (a) => a.id === parseInt(key)))) + .values() + .value() } +
+ } +
+ + { + !isFetching && isPopulated && + + + + + + + } + +
+ ); + } +} + +ConfirmImportModalContent.propTypes = { + albums: PropTypes.arrayOf(PropTypes.object).isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + onConfirmImportPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ConfirmImportModalContent; diff --git a/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContentConnector.js b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContentConnector.js new file mode 100644 index 000000000..dab76fb33 --- /dev/null +++ b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContentConnector.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchInteractiveImportTrackFiles, clearInteractiveImportTrackFiles } from 'Store/Actions/interactiveImportActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import ConfirmImportModalContent from './ConfirmImportModalContent'; + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('interactiveImport.trackFiles'), + (trackFiles) => { + return trackFiles; + } + ); +} + +const mapDispatchToProps = { + fetchInteractiveImportTrackFiles, + clearInteractiveImportTrackFiles +}; + +class ConfirmImportModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + albums + } = this.props; + + this.props.fetchInteractiveImportTrackFiles({ albumId: albums.map((x) => x.id) }); + } + + componentWillUnmount() { + this.props.clearInteractiveImportTrackFiles(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ConfirmImportModalContentConnector.propTypes = { + albums: PropTypes.arrayOf(PropTypes.object).isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchInteractiveImportTrackFiles: PropTypes.func.isRequired, + clearInteractiveImportTrackFiles: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ConfirmImportModalContentConnector); diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css index 522b6b6ca..2a7c4da4c 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css @@ -19,13 +19,13 @@ .centerButtons, .rightButtons { display: flex; - flex: 1 2 25%; + flex: 1 2 20%; flex-wrap: wrap; } .centerButtons { justify-content: center; - flex: 2 1 50%; + flex: 2 1 60%; } .rightButtons { diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js index ada5c2f38..06896c32b 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js @@ -22,6 +22,8 @@ import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal'; import SelectAlbumModal from 'InteractiveImport/Album/SelectAlbumModal'; +import SelectAlbumReleaseModal from 'InteractiveImport/AlbumRelease/SelectAlbumReleaseModal'; +import ConfirmImportModal from 'InteractiveImport/Confirmation/ConfirmImportModal'; import InteractiveImportRow from './InteractiveImportRow'; import styles from './InteractiveImportModalContent.css'; @@ -80,6 +82,11 @@ const filterExistingFilesOptions = { NEW: 'new' }; +const replaceExistingFilesOptions = { + COMBINE: 'combine', + DELETE: 'delete' +}; + class InteractiveImportModalContent extends Component { // @@ -95,10 +102,36 @@ class InteractiveImportModalContent extends Component { selectedState: {}, invalidRowsSelected: [], isSelectArtistModalOpen: false, - isSelectAlbumModalOpen: false + isSelectAlbumModalOpen: false, + isSelectAlbumReleaseModalOpen: false, + albumsImported: [], + isConfirmImportModalOpen: false, + showClearTracks: false, + inconsistentAlbumReleases: false }; } + componentDidUpdate(prevProps) { + const selectedIds = this.getSelectedIds(); + const selectedItems = _.filter(this.props.items, (x) => _.includes(selectedIds, x.id)); + const selectionHasTracks = _.some(selectedItems, (x) => x.tracks.length); + + if (this.state.showClearTracks !== selectionHasTracks) { + this.setState({ showClearTracks: selectionHasTracks }); + } + + const inconsistent = _(selectedItems) + .map((x) => ({ albumId: x.album ? x.album.id : 0, releaseId: x.albumReleaseId })) + .groupBy('albumId') + .mapValues((album) => _(album).groupBy((x) => x.releaseId).values().value().length) + .values() + .some((x) => x !== undefined && x > 1); + + if (inconsistent !== this.state.inconsistentAlbumReleases) { + this.setState({ inconsistentAlbumReleases: inconsistent }); + } + } + // // Control @@ -120,20 +153,38 @@ class InteractiveImportModalContent extends Component { } onValidRowChange = (id, isValid) => { - this.setState((state) => { - if (isValid) { - return { - invalidRowsSelected: _.without(state.invalidRowsSelected, id) - }; - } - - return { - invalidRowsSelected: [...state.invalidRowsSelected, id] - }; + this.setState((state, props) => { + // make sure to exclude any invalidRows that are no longer present in props + const diff = _.difference(state.invalidRowsSelected, _.map(props.items, 'id')); + const currentInvalid = _.difference(state.invalidRowsSelected, diff); + const newstate = isValid ? _.without(currentInvalid, id) : _.union(currentInvalid, [id]); + return { invalidRowsSelected: newstate }; }); } onImportSelectedPress = () => { + if (!this.props.replaceExistingFiles) { + this.onConfirmImportPress(); + return; + } + + // potentially deleting files + const selectedIds = this.getSelectedIds(); + const albumsImported = _(this.props.items) + .filter((x) => _.includes(selectedIds, x.id)) + .keyBy((x) => x.album.id) + .map((x) => x.album) + .value(); + + console.log(albumsImported); + + this.setState({ + albumsImported, + isConfirmImportModalOpen: true + }); + } + + onConfirmImportPress = () => { const { downloadId, showImportMode, @@ -151,6 +202,10 @@ class InteractiveImportModalContent extends Component { this.props.onFilterExistingFilesChange(value !== filterExistingFilesOptions.ALL); } + onReplaceExistingFilesChange = (value) => { + this.props.onReplaceExistingFilesChange(value === replaceExistingFilesOptions.DELETE); + } + onImportModeChange = ({ value }) => { this.props.onImportModeChange(value); } @@ -163,6 +218,10 @@ class InteractiveImportModalContent extends Component { this.setState({ isSelectAlbumModalOpen: true }); } + onSelectAlbumReleasePress = () => { + this.setState({ isSelectAlbumReleaseModalOpen: true }); + } + onClearTrackMappingPress = () => { const selectedIds = this.getSelectedIds(); @@ -175,6 +234,10 @@ class InteractiveImportModalContent extends Component { }); } + onGetTrackMappingPress = () => { + this.props.saveInteractiveImportItem({ id: this.getSelectedIds() }); + } + onSelectArtistModalClose = () => { this.setState({ isSelectArtistModalOpen: false }); } @@ -183,6 +246,14 @@ class InteractiveImportModalContent extends Component { this.setState({ isSelectAlbumModalOpen: false }); } + onSelectAlbumReleaseModalClose = () => { + this.setState({ isSelectAlbumReleaseModalOpen: false }); + } + + onConfirmImportModalClose = () => { + this.setState({ isConfirmImportModalOpen: false }); + } + // // Render @@ -191,12 +262,15 @@ class InteractiveImportModalContent extends Component { downloadId, allowArtistChange, showFilterExistingFiles, + showReplaceExistingFiles, showImportMode, filterExistingFiles, + replaceExistingFiles, title, folder, isFetching, isPopulated, + isSaving, error, items, sortKey, @@ -213,7 +287,12 @@ class InteractiveImportModalContent extends Component { selectedState, invalidRowsSelected, isSelectArtistModalOpen, - isSelectAlbumModalOpen + isSelectAlbumModalOpen, + isSelectAlbumReleaseModalOpen, + albumsImported, + isConfirmImportModalOpen, + showClearTracks, + inconsistentAlbumReleases } = this.state; const selectedIds = this.getSelectedIds(); @@ -232,43 +311,78 @@ class InteractiveImportModalContent extends Component { - { - showFilterExistingFiles && -
- - - - -
- { - filterExistingFiles ? 'Unmapped Files Only' : 'All Files' - } -
-
- - - - All Files - - - - Unmapped Files Only - - -
-
- } +
+ { + showFilterExistingFiles && + + + + +
+ { + filterExistingFiles ? 'Unmapped Files Only' : 'All Files' + } +
+
+ + + + All Files + + + + Unmapped Files Only + + +
+ } + { + showReplaceExistingFiles && + + + + +
+ { + replaceExistingFiles ? 'Existing files will be deleted' : 'Combine with existing files' + } +
+
+ + + + Combine With Existing Files + + + + Replace Existing Files + + +
+ } +
{ isFetching && @@ -299,6 +413,7 @@ class InteractiveImportModalContent extends Component { - + + { + showClearTracks ? ( + + ) : ( + + ) + }
@@ -366,7 +502,7 @@ class InteractiveImportModalContent extends Component {