diff --git a/frontend/src/AlbumStudio/AlbumStudio.css b/frontend/src/AlbumStudio/AlbumStudio.css new file mode 100644 index 000000000..033279591 --- /dev/null +++ b/frontend/src/AlbumStudio/AlbumStudio.css @@ -0,0 +1,36 @@ +.pageContentBodyWrapper { + display: flex; + flex: 1 0 1px; + overflow: hidden; +} + +.contentBody { + composes: contentBody from '~Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; +} + +.tableInnerContentBody { + composes: innerContentBody from '~Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.contentBodyContainer { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +@media only screen and (max-width: $breakpointSmall) { + .pageContentBodyWrapper { + flex-basis: auto; + } + + .contentBody { + flex-basis: 1px; + } +} diff --git a/frontend/src/AlbumStudio/AlbumStudio.js b/frontend/src/AlbumStudio/AlbumStudio.js index 6f84d6f3e..bae55799b 100644 --- a/frontend/src/AlbumStudio/AlbumStudio.js +++ b/frontend/src/AlbumStudio/AlbumStudio.js @@ -1,5 +1,8 @@ +import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import { CellMeasurer, CellMeasurerCache } from 'react-virtualized'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; import getSelectedIds from 'Utilities/Table/getSelectedIds'; import selectAll from 'Utilities/Table/selectAll'; @@ -8,17 +11,24 @@ import { align, sortDirections } from 'Helpers/Props'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; +import PageJumpBar from 'Components/Page/PageJumpBar'; import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import FilterMenu from 'Components/Menu/FilterMenu'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; +import VirtualTable from 'Components/Table/VirtualTable'; +import VirtualTableRow from 'Components/Table/VirtualTableRow'; import NoArtist from 'Artist/NoArtist'; import AlbumStudioFilterModalConnector from './AlbumStudioFilterModalConnector'; import AlbumStudioRowConnector from './AlbumStudioRowConnector'; +import AlbumStudioTableHeader from './AlbumStudioTableHeader'; import AlbumStudioFooter from './AlbumStudioFooter'; +import styles from './AlbumStudio.css'; const columns = [ + { + name: 'monitored', + isVisible: true + }, { name: 'status', isVisible: true @@ -29,14 +39,10 @@ const columns = [ isSortable: true, isVisible: true }, - { - name: 'monitored', - isVisible: true - }, { name: 'albumCount', label: 'Albums', - isSortable: true, + isSortable: false, isVisible: true } ]; @@ -50,11 +56,25 @@ class AlbumStudio extends Component { super(props, context); this.state = { + estimatedRowSize: 100, + scroller: null, + jumpBarItems: { order: [] }, + scrollIndex: null, + jumpCount: 0, allSelected: false, allUnselected: false, lastToggled: null, selectedState: {} }; + + this.cache = new CellMeasurerCache({ + defaultHeight: 100, + fixedWidth: true + }); + } + + componentDidMount() { + this.setSelectedState(); } componentDidUpdate(prevProps) { @@ -63,18 +83,186 @@ class AlbumStudio extends Component { saveError } = this.props; + const { + scrollIndex, + jumpCount + } = this.state; + if (prevProps.isSaving && !isSaving && !saveError) { this.onSelectAllChange({ value: false }); } + + // nasty hack to fix react-virtualized jumping incorrectly + // due to variable row heights + if (scrollIndex != null) { + if (jumpCount === 0) { + this.setState({ + scrollIndex: scrollIndex + 1, + jumpCount: 1 + }); + } else if (jumpCount === 1) { + this.setState({ + scrollIndex: scrollIndex - 1, + jumpCount: 2 + }); + } else { + this.setState({ + scrollIndex: null, + jumpCount: 0 + }); + } + } } // // Control + setScrollerRef = (ref) => { + this.setState({ scroller: ref }); + } + + setJumpBarItems() { + const { + items, + sortKey, + sortDirection + } = this.props; + + // Reset if not sorting by sortName + if (sortKey !== 'sortName') { + this.setState({ jumpBarItems: { order: [] } }); + return; + } + + const characters = _.reduce(items, (acc, item) => { + let char = item.sortName.charAt(0); + + if (!isNaN(char)) { + char = '#'; + } + + if (char in acc) { + acc[char] = acc[char] + 1; + } else { + acc[char] = 1; + } + + return acc; + }, {}); + + const order = Object.keys(characters).sort(); + + // Reverse if sorting descending + if (sortDirection === sortDirections.DESCENDING) { + order.reverse(); + } + + const jumpBarItems = { + characters, + order + }; + + this.setState({ jumpBarItems }); + } + getSelectedIds = () => { + if (this.state.allUnselected) { + return []; + } return getSelectedIds(this.state.selectedState); } + setSelectedState = () => { + const { + items + } = this.props; + + const { + selectedState + } = this.state; + + const newSelectedState = {}; + + items.forEach((artist) => { + const isItemSelected = selectedState[artist.id]; + + if (isItemSelected) { + newSelectedState[artist.id] = isItemSelected; + } else { + newSelectedState[artist.id] = false; + } + }); + + const selectedCount = getSelectedIds(newSelectedState).length; + const newStateCount = Object.keys(newSelectedState).length; + let isAllSelected = false; + let isAllUnselected = false; + + if (selectedCount === 0) { + isAllUnselected = true; + } else if (selectedCount === newStateCount) { + isAllSelected = true; + } + + this.setState({ selectedState: newSelectedState, allSelected: isAllSelected, allUnselected: isAllUnselected }); + } + + estimateRowHeight = (width) => { + const { + albumCount, + items + } = this.props; + + if (albumCount === undefined || albumCount === 0 || items.length === 0) { + return 100; + } + + // guess 250px per album entry + // available width is total width less 186px for select, status etc + const cols = Math.max(Math.floor((width - 186) / 250), 1); + const albumsPerArtist = albumCount / items.length; + const albumRowsPerArtist = albumsPerArtist / cols; + + // each row is 23px per album row plus 16px padding + return albumRowsPerArtist * 23 + 16; + } + + rowRenderer = ({ key, rowIndex, parent, style }) => { + const { + items + } = this.props; + + const { + selectedState + } = this.state; + + const item = items[rowIndex]; + + return ( + + {({ registerChild }) => ( + + + + )} + + ); + } + // // Listeners @@ -88,6 +276,10 @@ class AlbumStudio extends Component { }); } + onSelectAllPress = () => { + this.onSelectAllChange({ value: !this.state.allSelected }); + } + onUpdateSelectedPress = (changes) => { this.props.onUpdateSelectedPress({ artistIds: this.getSelectedIds(), @@ -95,6 +287,21 @@ class AlbumStudio extends Component { }); } + onJumpBarItemPress = (jumpToCharacter) => { + const scrollIndex = getIndexOfFirstCharacter(this.props.items, jumpToCharacter); + + if (scrollIndex != null) { + this.setState({ scrollIndex }); + } + } + + onGridRecompute = (width) => { + this.setJumpBarItems(); + this.setSelectedState(); + this.setState({ estimatedRowSize: this.estimateRowHeight(width) }); + this.cache.clearAll(); + } + // // Render @@ -112,6 +319,7 @@ class AlbumStudio extends Component { sortDirection, isSaving, saveError, + isSmallScreen, onSortPress, onFilterSelect } = this.props; @@ -119,7 +327,10 @@ class AlbumStudio extends Component { const { allSelected, allUnselected, - selectedState + estimatedRowSize, + scroller, + jumpBarItems, + scrollIndex } = this.state; return ( @@ -138,53 +349,68 @@ class AlbumStudio extends Component { - - { - isFetching && !isPopulated && - - } +
+ + { + isFetching && !isPopulated && + + } - { - !isFetching && !!error && -
{getErrorMessage(error, 'Failed to load artist from API')}
- } + { + !isFetching && !!error && +
{getErrorMessage(error, 'Failed to load artist from API')}
+ } - { - !error && isPopulated && !!items.length && -
- - - { - items.map((item) => { - return ( - - ); - }) + { + !error && isPopulated && !!items.length && +
+ } - -
-
- } + sortKey={sortKey} + sortDirection={sortDirection} + deferredMeasurementCache={this.cache} + rowHeight={this.cache.rowHeight} + estimatedRowSize={estimatedRowSize} + onRecompute={this.onGridRecompute} + /> +
+ } + + { + !error && isPopulated && !items.length && + + } +
{ - !error && isPopulated && !items.length && - + isPopulated && !!jumpBarItems.order.length && + } - + state.albums.items.length, + (state) => state.albums.isFetching, + (state) => state.albums.isPopulated, + (length, isFetching, isPopulated) => { + const albumCount = (!isFetching && isPopulated) ? length : 0; + return { + albumCount, + isFetching, + isPopulated + }; + } + ); +} + function createMapStateToProps() { return createSelector( - createClientSideCollectionSelector('artist', 'albumStudio'), - (artist) => { + createAlbumFetchStateSelector(), + createArtistClientSideCollectionItemsSelector('albumStudio'), + createDimensionsSelector(), + (albums, artist, dimensionsState) => { + const isPopulated = albums.isPopulated && artist.isPopulated; + const isFetching = artist.isFetching || albums.isFetching; return { - ...artist + ...artist, + isPopulated, + isFetching, + albumCount: albums.albumCount, + isSmallScreen: dimensionsState.isSmallScreen }; } ); diff --git a/frontend/src/AlbumStudio/AlbumStudioRow.css b/frontend/src/AlbumStudio/AlbumStudioRow.css index 7b9d1f52b..d998d68c0 100644 --- a/frontend/src/AlbumStudio/AlbumStudioRow.css +++ b/frontend/src/AlbumStudio/AlbumStudioRow.css @@ -1,20 +1,46 @@ +.cell { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + display: flex; + align-items: center; +} + +.selectCell { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + display: flex; + align-items: center; +} + .status, .monitored { composes: cell from '~Components/Table/Cells/TableRowCell.css'; - width: 50px; + padding: 0; + display: flex; + align-items: center; + width: 20px; +} + +.statusIcon { + width: 20px !important; } .title { composes: cell from '~Components/Table/Cells/TableRowCell.css'; - width: 1px; - white-space: nowrap; + display: flex; + align-items: center; + + flex-shrink: 0; + min-width: 110px; } .albums { - composes: cell from '~Components/Table/Cells/TableRowCell.css'; + composes: cell; display: flex; + flex-grow: 4; flex-wrap: wrap; + min-width: 400px; } diff --git a/frontend/src/AlbumStudio/AlbumStudioRow.js b/frontend/src/AlbumStudio/AlbumStudioRow.js index f6a146999..23e8a8204 100644 --- a/frontend/src/AlbumStudio/AlbumStudioRow.js +++ b/frontend/src/AlbumStudio/AlbumStudioRow.js @@ -3,9 +3,8 @@ import React, { Component } from 'react'; import { icons } from 'Helpers/Props'; import Icon from 'Components/Icon'; import MonitorToggleButton from 'Components/MonitorToggleButton'; -import TableRow from 'Components/Table/TableRow'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; import ArtistNameLink from 'Artist/ArtistNameLink'; import AlbumStudioAlbum from './AlbumStudioAlbum'; import styles from './AlbumStudioRow.css'; @@ -31,37 +30,40 @@ class AlbumStudioRow extends Component { } = this.props; return ( - - + - + + + + + - + - + - - - - - + - + { albums.map((album) => { return ( @@ -73,8 +75,8 @@ class AlbumStudioRow extends Component { ); }) } - - + + ); } } diff --git a/frontend/src/AlbumStudio/AlbumStudioRowConnector.js b/frontend/src/AlbumStudio/AlbumStudioRowConnector.js index 901f9407e..6c3b4c45a 100644 --- a/frontend/src/AlbumStudio/AlbumStudioRowConnector.js +++ b/frontend/src/AlbumStudio/AlbumStudioRowConnector.js @@ -8,12 +8,23 @@ import { toggleArtistMonitored } from 'Store/Actions/artistActions'; import { toggleAlbumsMonitored } from 'Store/Actions/albumActions'; import AlbumStudioRow from './AlbumStudioRow'; +// Use a const to share the reselect cache between instances +const getAlbumMap = createSelector( + (state) => state.albums.items, + (albums) => { + return albums.reduce((acc, curr) => { + (acc[curr.artistId] = acc[curr.artistId] || []).push(curr); + return acc; + }, {}); + } +); + function createMapStateToProps() { return createSelector( - (state) => state.albums, createArtistSelector(), - (albums, artist) => { - const albumsInArtist = _.filter(albums.items, { artistId: artist.id }); + getAlbumMap, + (artist, albumMap) => { + const albumsInArtist = albumMap.hasOwnProperty(artist.id) ? albumMap[artist.id] : []; const sortedAlbums = _.orderBy(albumsInArtist, 'releaseDate', 'desc'); return { diff --git a/frontend/src/AlbumStudio/AlbumStudioTableHeader.css b/frontend/src/AlbumStudio/AlbumStudioTableHeader.css new file mode 100644 index 000000000..c52e4dd26 --- /dev/null +++ b/frontend/src/AlbumStudio/AlbumStudioTableHeader.css @@ -0,0 +1,19 @@ +.monitored, +.status { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + width: 20px; + padding: 0; +} + +.sortName { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 110px; +} + +.albumCount { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + padding: 12px; +} diff --git a/frontend/src/AlbumStudio/AlbumStudioTableHeader.js b/frontend/src/AlbumStudio/AlbumStudioTableHeader.js new file mode 100644 index 000000000..88b934c5b --- /dev/null +++ b/frontend/src/AlbumStudio/AlbumStudioTableHeader.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; +import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell'; +import styles from './AlbumStudioTableHeader.css'; + +function AlbumStudioTableHeader(props) { + const { + columns, + allSelected, + allUnselected, + onSelectAllChange, + ...otherProps + } = props; + + return ( + + + { + columns.map((column) => { + const { + name, + label, + isSortable, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + return ( + + {label} + + ); + }) + } + + ); +} + +AlbumStudioTableHeader.propTypes = { + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + allSelected: PropTypes.bool.isRequired, + allUnselected: PropTypes.bool.isRequired, + onSelectAllChange: PropTypes.func.isRequired +}; + +export default AlbumStudioTableHeader; diff --git a/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js index a773aab58..0f5593c77 100644 --- a/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js +++ b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js @@ -42,6 +42,7 @@ class VirtualTableSelectCell extends Component { render() { const { + className, inputClassName, id, isSelected, @@ -51,7 +52,7 @@ class VirtualTableSelectCell extends Component { return ( {} }; export default VirtualTable;