New: Unmapped files view (#888)

* New: Unmapped files view

Displays all trackfiles that haven't been matched to a track.
Generalised the file details component and adds it to the album
details screen.

* Add sorting by quality

* New: MediaServiceTests & MediaRepoTests
This commit is contained in:
ta264 2019-08-25 16:49:30 +01:00 committed by Qstick
commit 4413c7e46c
36 changed files with 1507 additions and 404 deletions

View file

@ -1,34 +1,109 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import { icons, kinds } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import FileDetailsModal from 'TrackFile/FileDetailsModal';
import styles from './TrackActionsCell.css';
class TrackActionsCell extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false,
isConfirmDeleteModalOpen: false
};
}
//
// Listeners
onDetailsPress = () => {
this.setState({ isDetailsModalOpen: true });
}
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false });
}
onDeleteFilePress = () => {
this.setState({ isConfirmDeleteModalOpen: true });
}
onConfirmDelete = () => {
this.setState({ isConfirmDeleteModalOpen: false });
this.props.deleteTrackFile({ id: this.props.trackFileId });
}
onConfirmDeleteModalClose = () => {
this.setState({ isConfirmDeleteModalOpen: false });
}
//
// Render
render() {
return (
const {
trackFileId,
trackFilePath
} = this.props;
// TODO: Placeholder until we figure out what to show here.
const {
isDetailsModalOpen,
isConfirmDeleteModalOpen
} = this.state;
return (
<TableRowCell className={styles.TrackActionsCell}>
<IconButton
name={icons.DELETE}
title="Delete Track"
{
trackFilePath &&
<IconButton
name={icons.INFO}
onPress={this.onDetailsPress}
/>
}
{
trackFilePath &&
<IconButton
name={icons.DELETE}
onPress={this.onDeleteFilePress}
/>
}
<FileDetailsModal
isOpen={isDetailsModalOpen}
onModalClose={this.onDetailsModalClose}
id={trackFileId}
/>
<ConfirmModal
isOpen={isConfirmDeleteModalOpen}
kind={kinds.DANGER}
title="Delete Track File"
message={`Are you sure you want to delete ${trackFilePath}?`}
confirmLabel="Delete"
onConfirm={this.onConfirmDelete}
onCancel={this.onConfirmDeleteModalClose}
/>
</TableRowCell>
);
}
}
TrackActionsCell.propTypes = {
id: PropTypes.number.isRequired,
albumId: PropTypes.number.isRequired
albumId: PropTypes.number.isRequired,
trackFilePath: PropTypes.string,
trackFileId: PropTypes.number.isRequired,
deleteTrackFile: PropTypes.func.isRequired
};
export default TrackActionsCell;

View file

@ -12,24 +12,6 @@ import styles from './TrackRow.css';
class TrackRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false
};
}
//
// Listeners
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false });
}
//
// Render
@ -44,7 +26,8 @@ class TrackRow extends Component {
duration,
trackFilePath,
trackFileRelativePath,
columns
columns,
deleteTrackFile
} = this.props;
return (
@ -160,6 +143,9 @@ class TrackRow extends Component {
key={name}
albumId={albumId}
id={id}
trackFilePath={trackFilePath}
trackFileId={trackFileId}
deleteTrackFile={deleteTrackFile}
/>
);
}
@ -173,6 +159,7 @@ class TrackRow extends Component {
}
TrackRow.propTypes = {
deleteTrackFile: PropTypes.func.isRequired,
id: PropTypes.number.isRequired,
albumId: PropTypes.number.isRequired,
trackFileId: PropTypes.number,

View file

@ -1,6 +1,7 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector';
import { deleteTrackFile } from 'Store/Actions/trackFileActions';
import TrackRow from './TrackRow';
function createMapStateToProps() {
@ -15,4 +16,9 @@ function createMapStateToProps() {
}
);
}
export default connect(createMapStateToProps)(TrackRow);
const mapDispatchToProps = {
deleteTrackFile
};
export default connect(createMapStateToProps, mapDispatchToProps)(TrackRow);

View file

@ -9,6 +9,7 @@ import AddNewArtistConnector from 'AddArtist/AddNewArtist/AddNewArtistConnector'
import ImportArtist from 'AddArtist/ImportArtist/ImportArtist';
import ArtistEditorConnector from 'Artist/Editor/ArtistEditorConnector';
import AlbumStudioConnector from 'AlbumStudio/AlbumStudioConnector';
import UnmappedFilesTableConnector from 'UnmappedFiles/UnmappedFilesTableConnector';
import ArtistDetailsPageConnector from 'Artist/Details/ArtistDetailsPageConnector';
import AlbumDetailsPageConnector from 'Album/Details/AlbumDetailsPageConnector';
import CalendarPageConnector from 'Calendar/CalendarPageConnector';
@ -90,6 +91,11 @@ function AppRoutes(props) {
component={AlbumStudioConnector}
/>
<Route
path="/unmapped"
component={UnmappedFilesTableConnector}
/>
<Route
path="/artist/:foreignArtistId"
component={ArtistDetailsPageConnector}

View file

@ -20,7 +20,7 @@ const SIDEBAR_WIDTH = parseInt(dimensions.sidebarWidth);
const links = [
{
iconName: icons.ARTIST_CONTINUING,
title: 'Artist',
title: 'Library',
to: '/',
alias: '/artist',
children: [
@ -39,6 +39,10 @@ const links = [
{
title: 'Album Studio',
to: '/albumstudio'
},
{
title: 'Unmapped Files',
to: '/unmapped'
}
]
},

View file

@ -208,12 +208,12 @@ class SignalRConnector extends Component {
if (body.action === 'updated') {
this.props.dispatchUpdateItem({ section, ...body.resource });
// Repopulate the page to handle recently imported file
repopulatePage('trackFileUpdated');
} else if (body.action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: body.resource.id });
}
// Repopulate the page to handle recently imported file
repopulatePage('trackFileUpdated');
}
handleHealth = () => {

View file

@ -1,258 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
import styles from './FileDetails.css';
class FileDetails extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isExpanded: props.isExpanded
};
}
//
// Listeners
onExpandPress = () => {
const {
isExpanded
} = this.state;
this.setState({ isExpanded: !isExpanded });
}
//
// Render
renderRejections() {
const {
rejections
} = this.props;
return (
<span>
<DescriptionListItemTitle>
Rejections
</DescriptionListItemTitle>
{
_.map(rejections, (item, key) => {
return (
<DescriptionListItemDescription key={key}>
{item.reason}
</DescriptionListItemDescription>
);
})
}
</span>
);
}
render() {
const {
filename,
audioTags,
rejections
} = this.props;
const {
isExpanded
} = this.state;
return (
<div
className={styles.fileDetails}
>
<div className={styles.header} onClick={this.onExpandPress}>
<div className={styles.filename}>
{filename}
</div>
<div className={styles.expandButton}>
<Icon
className={styles.expandButtonIcon}
name={isExpanded ? icons.COLLAPSE : icons.EXPAND}
title={isExpanded ? 'Hide file info' : 'Show file info'}
size={24}
/>
</div>
</div>
<div>
{
isExpanded &&
<div className={styles.audioTags}>
<DescriptionList>
{
audioTags.title !== undefined &&
<DescriptionListItem
title="Track Title"
data={audioTags.title}
/>
}
{
audioTags.trackNumbers[0] > 0 &&
<DescriptionListItem
title="Track Number"
data={audioTags.trackNumbers[0]}
/>
}
{
audioTags.discNumber > 0 &&
<DescriptionListItem
title="Disc Number"
data={audioTags.discNumber}
/>
}
{
audioTags.discCount > 0 &&
<DescriptionListItem
title="Disc Count"
data={audioTags.discCount}
/>
}
{
audioTags.albumTitle !== undefined &&
<DescriptionListItem
title="Album"
data={audioTags.albumTitle}
/>
}
{
audioTags.artistTitle !== undefined &&
<DescriptionListItem
title="Artist"
data={audioTags.artistTitle}
/>
}
{
audioTags.country !== undefined &&
<DescriptionListItem
title="Country"
data={audioTags.country.name}
/>
}
{
audioTags.year > 0 &&
<DescriptionListItem
title="Year"
data={audioTags.year}
/>
}
{
audioTags.label !== undefined &&
<DescriptionListItem
title="Label"
data={audioTags.label}
/>
}
{
audioTags.catalogNumber !== undefined &&
<DescriptionListItem
title="Catalog Number"
data={audioTags.catalogNumber}
/>
}
{
audioTags.disambiguation !== undefined &&
<DescriptionListItem
title="Disambiguation"
data={audioTags.disambiguation}
/>
}
{
audioTags.duration !== undefined &&
<DescriptionListItem
title="Duration"
data={formatTimeSpan(audioTags.duration)}
/>
}
{
audioTags.artistMBId !== undefined &&
<Link
to={`https://musicbrainz.org/artist/${audioTags.artistMBId}`}
>
<DescriptionListItem
title="MusicBrainz Artist ID"
data={audioTags.artistMBId}
/>
</Link>
}
{
audioTags.albumMBId !== undefined &&
<Link
to={`https://musicbrainz.org/release-group/${audioTags.albumMBId}`}
>
<DescriptionListItem
title="MusicBrainz Album ID"
data={audioTags.albumMBId}
/>
</Link>
}
{
audioTags.releaseMBId !== undefined &&
<Link
to={`https://musicbrainz.org/release/${audioTags.releaseMBId}`}
>
<DescriptionListItem
title="MusicBrainz Release ID"
data={audioTags.releaseMBId}
/>
</Link>
}
{
audioTags.recordingMBId !== undefined &&
<Link
to={`https://musicbrainz.org/recording/${audioTags.recordingMBId}`}
>
<DescriptionListItem
title="MusicBrainz Recording ID"
data={audioTags.recordingMBId}
/>
</Link>
}
{
audioTags.trackMBId !== undefined &&
<Link
to={`https://musicbrainz.org/track/${audioTags.trackMBId}`}
>
<DescriptionListItem
title="MusicBrainz Track ID"
data={audioTags.trackMBId}
/>
</Link>
}
{
rejections.length > 0 &&
this.renderRejections()
}
</DescriptionList>
</div>
}
</div>
</div>
);
}
}
FileDetails.propTypes = {
audioTags: PropTypes.object.isRequired,
filename: PropTypes.string.isRequired,
rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
isExpanded: PropTypes.bool
};
export default FileDetails;

View file

@ -15,7 +15,7 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import SelectTrackRow from './SelectTrackRow';
import FileDetails from '../FileDetails';
import ExpandingFileDetails from 'TrackFile/ExpandingFileDetails';
const columns = [
{
@ -151,7 +151,7 @@ class SelectTrackModalContent extends Component {
<div>{errorMessage}</div>
}
<FileDetails
<ExpandingFileDetails
audioTags={audioTags}
filename={filename}
rejections={rejections}

View file

@ -47,7 +47,7 @@ export const defaultState = {
},
quality: function(item, direction) {
return item.quality ? item.quality.qualityWeight : 0;
return item.quality ? item.qualityWeight : 0;
}
},

View file

@ -2,7 +2,11 @@ import _ from 'lodash';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
import createClearReducer from './Creators/Reducers/createClearReducer';
import albumEntities from 'Album/albumEntities';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
@ -20,14 +24,62 @@ export const section = 'trackFiles';
export const defaultState = {
isFetching: false,
isPopulated: false,
sortKey: 'path',
sortDirection: sortDirections.ASCENDING,
error: null,
isDeleting: false,
deleteError: null,
isSaving: false,
saveError: null,
items: []
items: [],
sortPredicates: {
quality: function(item, direction) {
return item.quality ? item.qualityWeight : 0;
}
},
columns: [
{
name: 'path',
label: 'Path',
isSortable: true,
isVisible: true,
isModifiable: false
},
{
name: 'size',
label: 'Size',
isSortable: true,
isVisible: true
},
{
name: 'dateAdded',
label: 'Date Added',
isSortable: true,
isVisible: true
},
{
name: 'quality',
label: 'Quality',
isSortable: true,
isVisible: true
},
{
name: 'actions',
columnLabel: 'Actions',
isVisible: true,
isModifiable: false
}
]
};
export const persistState = [
'trackFiles.sortKey',
'trackFiles.sortDirection'
];
//
// Actions Types
@ -35,6 +87,8 @@ export const FETCH_TRACK_FILES = 'trackFiles/fetchTrackFiles';
export const DELETE_TRACK_FILE = 'trackFiles/deleteTrackFile';
export const DELETE_TRACK_FILES = 'trackFiles/deleteTrackFiles';
export const UPDATE_TRACK_FILES = 'trackFiles/updateTrackFiles';
export const SET_TRACK_FILES_SORT = 'trackFiles/setTrackFilesSort';
export const SET_TRACK_FILES_TABLE_OPTION = 'trackFiles/setTrackFilesTableOption';
export const CLEAR_TRACK_FILES = 'trackFiles/clearTrackFiles';
//
@ -44,6 +98,8 @@ export const fetchTrackFiles = createThunk(FETCH_TRACK_FILES);
export const deleteTrackFile = createThunk(DELETE_TRACK_FILE);
export const deleteTrackFiles = createThunk(DELETE_TRACK_FILES);
export const updateTrackFiles = createThunk(UPDATE_TRACK_FILES);
export const setTrackFilesSort = createAction(SET_TRACK_FILES_SORT);
export const setTrackFilesTableOption = createAction(SET_TRACK_FILES_TABLE_OPTION);
export const clearTrackFiles = createAction(CLEAR_TRACK_FILES);
//
@ -193,9 +249,14 @@ export const actionHandlers = handleThunks({
// Reducers
export const reducers = createHandleActions({
[SET_TRACK_FILES_SORT]: createSetClientSideCollectionSortReducer(section),
[SET_TRACK_FILES_TABLE_OPTION]: createSetTableOptionReducer(section),
[CLEAR_TRACK_FILES]: (state) => {
return Object.assign({}, state, defaultState);
}
[CLEAR_TRACK_FILES]: createClearReducer(section, {
isFetching: false,
isPopulated: false,
error: null,
items: []
})
}, defaultState, section);

View file

@ -9,6 +9,14 @@
}
}
.filename {
flex-grow: 1;
margin-right: 10px;
margin-left: 10px;
font-size: 14px;
font-family: $monoSpaceFontFamily;
}
.header {
position: relative;
display: flex;
@ -17,12 +25,6 @@
font-size: 18px;
}
.filename {
flex-grow: 1;
margin-right: 10px;
margin-left: 10px;
}
.expandButton {
position: relative;
width: 60px;
@ -35,12 +37,6 @@
width: 30px;
}
.audioTags {
padding-top: 15px;
padding-bottom: 15px;
border-top: 1px solid $borderColor;
}
.expandButtonIcon {
composes: actionButton;

View file

@ -0,0 +1,83 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import FileDetails from './FileDetails';
import styles from './ExpandingFileDetails.css';
class ExpandingFileDetails extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isExpanded: props.isExpanded
};
}
//
// Listeners
onExpandPress = () => {
const {
isExpanded
} = this.state;
this.setState({ isExpanded: !isExpanded });
}
//
// Render
render() {
const {
filename,
audioTags,
rejections
} = this.props;
const {
isExpanded
} = this.state;
return (
<div
className={styles.fileDetails}
>
<div className={styles.header} onClick={this.onExpandPress}>
<div className={styles.filename}>
{filename}
</div>
<div className={styles.expandButton}>
<Icon
className={styles.expandButtonIcon}
name={isExpanded ? icons.COLLAPSE : icons.EXPAND}
title={isExpanded ? 'Hide file info' : 'Show file info'}
size={24}
/>
</div>
</div>
{
isExpanded &&
<FileDetails
audioTags={audioTags}
rejections={rejections}
/>
}
</div>
);
}
}
ExpandingFileDetails.propTypes = {
audioTags: PropTypes.object.isRequired,
filename: PropTypes.string.isRequired,
rejections: PropTypes.arrayOf(PropTypes.object),
isExpanded: PropTypes.bool
};
export default ExpandingFileDetails;

View file

@ -0,0 +1,11 @@
.audioTags {
padding-top: 15px;
padding-bottom: 15px;
/* border-top: 1px solid $borderColor; */
}
.filename {
composes: description from '~Components/DescriptionList/DescriptionListItemDescription.css';
font-family: $monoSpaceFontFamily;
}

View file

@ -0,0 +1,206 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Fragment } from 'react';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import Link from 'Components/Link/Link';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
import styles from './FileDetails.css';
function renderRejections(rejections) {
return (
<span>
<DescriptionListItemTitle>
Rejections
</DescriptionListItemTitle>
{
_.map(rejections, (item, key) => {
return (
<DescriptionListItemDescription key={key}>
{item.reason}
</DescriptionListItemDescription>
);
})
}
</span>
);
}
function FileDetails(props) {
const {
filename,
audioTags,
rejections
} = props;
return (
<Fragment>
<div className={styles.audioTags}>
<DescriptionList>
{
filename &&
<DescriptionListItem
title="Filename"
data={filename}
descriptionClassName={styles.filename}
/>
}
{
audioTags.title !== undefined &&
<DescriptionListItem
title="Track Title"
data={audioTags.title}
/>
}
{
audioTags.trackNumbers[0] > 0 &&
<DescriptionListItem
title="Track Number"
data={audioTags.trackNumbers[0]}
/>
}
{
audioTags.discNumber > 0 &&
<DescriptionListItem
title="Disc Number"
data={audioTags.discNumber}
/>
}
{
audioTags.discCount > 0 &&
<DescriptionListItem
title="Disc Count"
data={audioTags.discCount}
/>
}
{
audioTags.albumTitle !== undefined &&
<DescriptionListItem
title="Album"
data={audioTags.albumTitle}
/>
}
{
audioTags.artistTitle !== undefined &&
<DescriptionListItem
title="Artist"
data={audioTags.artistTitle}
/>
}
{
audioTags.country !== undefined &&
<DescriptionListItem
title="Country"
data={audioTags.country.name}
/>
}
{
audioTags.year > 0 &&
<DescriptionListItem
title="Year"
data={audioTags.year}
/>
}
{
audioTags.label !== undefined &&
<DescriptionListItem
title="Label"
data={audioTags.label}
/>
}
{
audioTags.catalogNumber !== undefined &&
<DescriptionListItem
title="Catalog Number"
data={audioTags.catalogNumber}
/>
}
{
audioTags.disambiguation !== undefined &&
<DescriptionListItem
title="Disambiguation"
data={audioTags.disambiguation}
/>
}
{
audioTags.duration !== undefined &&
<DescriptionListItem
title="Duration"
data={formatTimeSpan(audioTags.duration)}
/>
}
{
audioTags.artistMBId !== undefined &&
<Link
to={`https://musicbrainz.org/artist/${audioTags.artistMBId}`}
>
<DescriptionListItem
title="MusicBrainz Artist ID"
data={audioTags.artistMBId}
/>
</Link>
}
{
audioTags.albumMBId !== undefined &&
<Link
to={`https://musicbrainz.org/release-group/${audioTags.albumMBId}`}
>
<DescriptionListItem
title="MusicBrainz Album ID"
data={audioTags.albumMBId}
/>
</Link>
}
{
audioTags.releaseMBId !== undefined &&
<Link
to={`https://musicbrainz.org/release/${audioTags.releaseMBId}`}
>
<DescriptionListItem
title="MusicBrainz Release ID"
data={audioTags.releaseMBId}
/>
</Link>
}
{
audioTags.recordingMBId !== undefined &&
<Link
to={`https://musicbrainz.org/recording/${audioTags.recordingMBId}`}
>
<DescriptionListItem
title="MusicBrainz Recording ID"
data={audioTags.recordingMBId}
/>
</Link>
}
{
audioTags.trackMBId !== undefined &&
<Link
to={`https://musicbrainz.org/track/${audioTags.trackMBId}`}
>
<DescriptionListItem
title="MusicBrainz Track ID"
data={audioTags.trackMBId}
/>
</Link>
}
{
!!rejections && rejections.length > 0 &&
renderRejections(rejections)
}
</DescriptionList>
</div>
</Fragment>
);
}
FileDetails.propTypes = {
filename: PropTypes.string,
audioTags: PropTypes.object.isRequired,
rejections: PropTypes.arrayOf(PropTypes.object)
};
export default FileDetails;

View file

@ -0,0 +1,77 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import { fetchTrackFiles } from 'Store/Actions/trackFileActions';
import FileDetails from './FileDetails';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
function createMapStateToProps() {
return createSelector(
(state) => state.trackFiles,
(trackFiles) => {
return {
...trackFiles
};
}
);
}
const mapDispatchToProps = {
fetchTrackFiles
};
class FileDetailsConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchTrackFiles({ id: this.props.id });
}
//
// Render
render() {
const {
items,
id,
isFetching,
error
} = this.props;
const item = _.find(items, { id });
const errorMessage = getErrorMessage(error, 'Unable to load manual import items');
if (isFetching || !item.audioTags) {
return (
<LoadingIndicator />
);
} else if (error) {
return (
<div>{errorMessage}</div>
);
}
return (
<FileDetails
audioTags={item.audioTags}
filename={item.path}
/>
);
}
}
FileDetailsConnector.propTypes = {
fetchTrackFiles: PropTypes.func.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
id: PropTypes.number.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object
};
export default connect(createMapStateToProps, mapDispatchToProps)(FileDetailsConnector);

View file

@ -0,0 +1,52 @@
import PropTypes from 'prop-types';
import React from 'react';
import FileDetailsConnector from './FileDetailsConnector';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
function FileDetailsModal(props) {
const {
isOpen,
onModalClose,
id
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ModalContent
onModalClose={onModalClose}
>
<ModalHeader>
Details
</ModalHeader>
<ModalBody>
<FileDetailsConnector
id={id}
/>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
FileDetailsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired,
id: PropTypes.number.isRequired
};
export default FileDetailsModal;

View file

@ -0,0 +1,161 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { align, icons, sortDirections } from 'Helpers/Props';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import VirtualTable from 'Components/Table/VirtualTable';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import UnmappedFilesTableRow from './UnmappedFilesTableRow';
import UnmappedFilesTableHeader from './UnmappedFilesTableHeader';
class UnmappedFilesTable extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
contentBody: null,
scrollTop: 0
};
}
//
// Control
setContentBodyRef = (ref) => {
this.setState({ contentBody: ref });
}
rowRenderer = ({ key, rowIndex, style }) => {
const {
items,
columns,
deleteUnmappedFile
} = this.props;
const item = items[rowIndex];
return (
<UnmappedFilesTableRow
style={style}
key={key}
columns={columns}
deleteUnmappedFile={deleteUnmappedFile}
{...item}
/>
);
}
//
// Listeners
onScroll = ({ scrollTop }) => {
this.setState({ scrollTop });
}
render() {
const {
isFetching,
isPopulated,
error,
items,
columns,
sortKey,
sortDirection,
onTableOptionChange,
onSortPress,
deleteUnmappedFile,
...otherProps
} = this.props;
const {
scrollTop,
contentBody
} = this.state;
return (
<PageContent title="UnmappedFiles">
<PageToolbar>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
onTableOptionChange={onTableOptionChange}
>
<PageToolbarButton
label="Options"
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
</PageToolbarSection>
</PageToolbar>
<PageContentBodyConnector
ref={this.setContentBodyRef}
onScroll={this.onScroll}
>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
isPopulated && !error && !items.length &&
<div>
Success! My work is done, all files on disk are matched to known tracks.
</div>
}
{
isPopulated && !error && !!items.length && contentBody &&
<VirtualTable
items={items}
columns={columns}
contentBody={contentBody}
isSmallScreen={false}
scrollTop={scrollTop}
onScroll={this.onScroll}
overscanRowCount={10}
rowRenderer={this.rowRenderer}
header={
<UnmappedFilesTableHeader
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onTableOptionChange={onTableOptionChange}
onSortPress={onSortPress}
/>
}
sortKey={sortKey}
sortDirection={sortDirection}
/>
}
</PageContentBodyConnector>
</PageContent>
);
}
}
UnmappedFilesTable.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
onTableOptionChange: PropTypes.func.isRequired,
onSortPress: PropTypes.func.isRequired,
deleteUnmappedFile: PropTypes.func.isRequired
};
export default UnmappedFilesTable;

View file

@ -0,0 +1,100 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { fetchTrackFiles, deleteTrackFile, setTrackFilesSort, setTrackFilesTableOption } from 'Store/Actions/trackFileActions';
import withCurrentPage from 'Components/withCurrentPage';
import UnmappedFilesTable from './UnmappedFilesTable';
function createMapStateToProps() {
return createSelector(
createClientSideCollectionSelector('trackFiles'),
createDimensionsSelector(),
(
trackFiles,
dimensionsState
) => {
// trackFiles could pick up mapped entries via signalR so filter again here
const {
items,
...otherProps
} = trackFiles;
const unmappedFiles = _.filter(items, { albumId: 0 });
return {
items: unmappedFiles,
...otherProps,
isSmallScreen: dimensionsState.isSmallScreen
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onTableOptionChange(payload) {
dispatch(setTrackFilesTableOption(payload));
},
onSortPress(sortKey) {
dispatch(setTrackFilesSort({ sortKey }));
},
fetchUnmappedFiles() {
dispatch(fetchTrackFiles({ unmapped: true }));
},
deleteUnmappedFile(id) {
dispatch(deleteTrackFile({ id }));
}
};
}
class UnmappedFilesTableConnector extends Component {
//
// Lifecycle
componentDidMount() {
registerPagePopulator(this.repopulate, ['trackFileUpdated']);
this.repopulate();
}
componentWillUnmount() {
unregisterPagePopulator(this.repopulate);
}
//
// Control
repopulate = () => {
this.props.fetchUnmappedFiles();
}
//
// Render
render() {
return (
<UnmappedFilesTable
{...this.props}
/>
);
}
}
UnmappedFilesTableConnector.propTypes = {
isSmallScreen: PropTypes.bool.isRequired,
onSortPress: PropTypes.func.isRequired,
onTableOptionChange: PropTypes.func.isRequired,
fetchUnmappedFiles: PropTypes.func.isRequired,
deleteUnmappedFile: PropTypes.func.isRequired
};
export default withCurrentPage(
connect(createMapStateToProps, createMapDispatchToProps)(UnmappedFilesTableConnector)
);

View file

@ -0,0 +1,19 @@
.quality,
.size,
.dateAdded {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 120px;
}
.path {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 4 0 400px;
}
.actions {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 1 90px;
}

View file

@ -0,0 +1,77 @@
import PropTypes from 'prop-types';
import React from 'react';
import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
// import hasGrowableColumns from './hasGrowableColumns';
import styles from './UnmappedFilesTableHeader.css';
function UnmappedFilesTableHeader(props) {
const {
columns,
onTableOptionChange,
...otherProps
} = props;
return (
<VirtualTableHeader>
{
columns.map((column) => {
const {
name,
label,
isSortable,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'actions') {
return (
<VirtualTableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={false}
{...otherProps}
>
<TableOptionsModalWrapper
columns={columns}
onTableOptionChange={onTableOptionChange}
>
<IconButton
name={icons.ADVANCED_SETTINGS}
/>
</TableOptionsModalWrapper>
</VirtualTableHeaderCell>
);
}
return (
<VirtualTableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={isSortable}
{...otherProps}
>
{label}
</VirtualTableHeaderCell>
);
})
}
</VirtualTableHeader>
);
}
UnmappedFilesTableHeader.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onTableOptionChange: PropTypes.func.isRequired
};
export default UnmappedFilesTableHeader;

View file

@ -0,0 +1,22 @@
.path {
composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
flex: 4 0 400px;
font-size: 13px;
font-family: $monoSpaceFontFamily;
}
.quality,
.dateAdded,
.size {
composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 0 120px;
white-space: nowrap;
}
.actions {
composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 0 90px;
}

View file

@ -0,0 +1,218 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, kinds } from 'Helpers/Props';
import formatBytes from 'Utilities/Number/formatBytes';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import VirtualTableRow from 'Components/Table/VirtualTableRow';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import TrackQuality from 'Album/TrackQuality';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import FileDetailsModal from 'TrackFile/FileDetailsModal';
import styles from './UnmappedFilesTableRow.css';
class UnmappedFilesTableRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false,
isInteractiveImportModalOpen: false,
isConfirmDeleteModalOpen: false
};
}
//
// Listeners
onDetailsPress = () => {
this.setState({ isDetailsModalOpen: true });
}
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false });
}
onInteractiveImportPress = () => {
this.setState({ isInteractiveImportModalOpen: true });
}
onInteractiveImportModalClose = () => {
this.setState({ isInteractiveImportModalOpen: false });
}
onDeleteFilePress = () => {
this.setState({ isConfirmDeleteModalOpen: true });
}
onConfirmDelete = () => {
this.setState({ isConfirmDeleteModalOpen: false });
this.props.deleteUnmappedFile(this.props.id);
}
onConfirmDeleteModalClose = () => {
this.setState({ isConfirmDeleteModalOpen: false });
}
//
// Render
render() {
const {
style,
id,
path,
size,
dateAdded,
quality,
columns
} = this.props;
const folder = path.substring(0, Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')));
const {
isInteractiveImportModalOpen,
isDetailsModalOpen,
isConfirmDeleteModalOpen
} = this.state;
return (
<VirtualTableRow
style={style}
>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'path') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{path}
</VirtualTableRowCell>
);
}
if (name === 'size') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{formatBytes(size)}
</VirtualTableRowCell>
);
}
if (name === 'dateAdded') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={dateAdded}
component={VirtualTableRowCell}
/>
);
}
if (name === 'quality') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<TrackQuality
quality={quality}
/>
</VirtualTableRowCell>
);
}
if (name === 'actions') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<IconButton
name={icons.INFO}
onPress={this.onDetailsPress}
/>
<IconButton
name={icons.INTERACTIVE}
onPress={this.onInteractiveImportPress}
/>
<IconButton
name={icons.DELETE}
onPress={this.onDeleteFilePress}
/>
</VirtualTableRowCell>
);
}
return null;
})
}
<InteractiveImportModal
isOpen={isInteractiveImportModalOpen}
folder={folder}
showFilterExistingFiles={true}
filterExistingFiles={false}
showImportMode={false}
showReplaceExistingFiles={false}
replaceExistingFiles={false}
onModalClose={this.onInteractiveImportModalClose}
/>
<FileDetailsModal
isOpen={isDetailsModalOpen}
onModalClose={this.onDetailsModalClose}
id={id}
/>
<ConfirmModal
isOpen={isConfirmDeleteModalOpen}
kind={kinds.DANGER}
title="Delete Track File"
message={`Are you sure you want to delete ${path}?`}
confirmLabel="Delete"
onConfirm={this.onConfirmDelete}
onCancel={this.onConfirmDeleteModalClose}
/>
</VirtualTableRow>
);
}
}
UnmappedFilesTableRow.propTypes = {
style: PropTypes.object.isRequired,
id: PropTypes.number.isRequired,
path: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
quality: PropTypes.object.isRequired,
dateAdded: PropTypes.string.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
deleteUnmappedFile: PropTypes.func.isRequired
};
export default UnmappedFilesTableRow;