Fixed: Speed up AlbumStudio

This commit is contained in:
ta264 2020-08-26 22:38:48 +01:00 committed by Qstick
parent dce69212fc
commit e681469857
10 changed files with 503 additions and 87 deletions

View File

@ -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;
}
}

View File

@ -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 (
<CellMeasurer
key={key}
cache={this.cache}
parent={parent}
columnIndex={0}
rowIndex={rowIndex}
>
{({ registerChild }) => (
<VirtualTableRow
ref={registerChild}
style={style}
>
<AlbumStudioRowConnector
key={item.id}
artistId={item.id}
isSelected={selectedState[item.id]}
onSelectedChange={this.onSelectedChange}
/>
</VirtualTableRow>
)}
</CellMeasurer>
);
}
//
// 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 {
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
<div className={styles.pageContentBodyWrapper}>
<PageContentBody
registerScroller={this.setScrollerRef}
className={styles.contentBody}
innerClassName={styles.innerContentBody}
>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>{getErrorMessage(error, 'Failed to load artist from API')}</div>
}
{
!isFetching && !!error &&
<div>{getErrorMessage(error, 'Failed to load artist from API')}</div>
}
{
!error && isPopulated && !!items.length &&
<div>
<Table
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
onSortPress={onSortPress}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<AlbumStudioRowConnector
key={item.id}
artistId={item.id}
isSelected={selectedState[item.id]}
onSelectedChange={this.onSelectedChange}
/>
);
})
{
!error && isPopulated && !!items.length &&
<div className={styles.contentBodyContainer}>
<VirtualTable
items={items}
scrollIndex={scrollIndex}
columns={columns}
scroller={scroller}
isSmallScreen={isSmallScreen}
overscanRowCount={5}
rowRenderer={this.rowRenderer}
header={
<AlbumStudioTableHeader
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={this.onSelectAllChange}
/>
}
</TableBody>
</Table>
</div>
}
sortKey={sortKey}
sortDirection={sortDirection}
deferredMeasurementCache={this.cache}
rowHeight={this.cache.rowHeight}
estimatedRowSize={estimatedRowSize}
onRecompute={this.onGridRecompute}
/>
</div>
}
{
!error && isPopulated && !items.length &&
<NoArtist totalItems={totalItems} />
}
</PageContentBody>
{
!error && isPopulated && !items.length &&
<NoArtist totalItems={totalItems} />
isPopulated && !!jumpBarItems.order.length &&
<PageJumpBar
items={jumpBarItems}
onItemPress={this.onJumpBarItemPress}
/>
}
</PageContentBody>
</div>
<AlbumStudioFooter
selectedCount={this.getSelectedIds().length}
@ -203,6 +429,7 @@ AlbumStudio.propTypes = {
error: PropTypes.object,
totalItems: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
albumCount: PropTypes.number.isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
@ -210,6 +437,7 @@ AlbumStudio.propTypes = {
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
isSmallScreen: PropTypes.bool.isRequired,
onSortPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onUpdateSelectedPress: PropTypes.func.isRequired

View File

@ -2,17 +2,42 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createArtistClientSideCollectionItemsSelector from 'Store/Selectors/createArtistClientSideCollectionItemsSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { setAlbumStudioSort, setAlbumStudioFilter, saveAlbumStudio } from 'Store/Actions/albumStudioActions';
import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions';
import AlbumStudio from './AlbumStudio';
function createAlbumFetchStateSelector() {
return createSelector(
(state) => 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
};
}
);

View File

@ -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;
}

View File

@ -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 (
<TableRow>
<TableSelectCell
<>
<VirtualTableSelectCell
className={styles.selectCell}
id={artistId}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
isDisabled={false}
/>
<TableRowCell className={styles.status}>
<VirtualTableRowCell className={styles.monitored}>
<MonitorToggleButton
monitored={monitored}
size={14}
isSaving={isSaving}
onPress={onArtistMonitoredPress}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.status}>
<Icon
className={styles.statusIcon}
name={status === 'ended' ? icons.ARTIST_ENDED : icons.ARTIST_CONTINUING}
title={status === 'ended' ? 'Ended' : 'Continuing'}
/>
</TableRowCell>
</VirtualTableRowCell>
<TableRowCell className={styles.title}>
<VirtualTableRowCell className={styles.title}>
<ArtistNameLink
foreignArtistId={foreignArtistId}
artistName={artistName}
/>
</TableRowCell>
</VirtualTableRowCell>
<TableRowCell className={styles.monitored}>
<MonitorToggleButton
monitored={monitored}
isSaving={isSaving}
onPress={onArtistMonitoredPress}
/>
</TableRowCell>
<TableRowCell className={styles.albums}>
<VirtualTableRowCell className={styles.albums}>
{
albums.map((album) => {
return (
@ -73,8 +75,8 @@ class AlbumStudioRow extends Component {
);
})
}
</TableRowCell>
</TableRow>
</VirtualTableRowCell>
</>
);
}
}

View File

@ -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 {

View File

@ -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;
}

View File

@ -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 (
<VirtualTableHeader>
<VirtualTableSelectAllHeaderCell
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
/>
{
columns.map((column) => {
const {
name,
label,
isSortable,
isVisible
} = column;
if (!isVisible) {
return null;
}
return (
<VirtualTableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={isSortable}
{...otherProps}
>
{label}
</VirtualTableHeaderCell>
);
})
}
</VirtualTableHeader>
);
}
AlbumStudioTableHeader.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
allSelected: PropTypes.bool.isRequired,
allUnselected: PropTypes.bool.isRequired,
onSelectAllChange: PropTypes.func.isRequired
};
export default AlbumStudioTableHeader;

View File

@ -42,6 +42,7 @@ class VirtualTableSelectCell extends Component {
render() {
const {
className,
inputClassName,
id,
isSelected,
@ -51,7 +52,7 @@ class VirtualTableSelectCell extends Component {
return (
<VirtualTableRowCell
className={styles.cell}
className={className}
{...otherProps}
>
<CheckInput
@ -67,6 +68,7 @@ class VirtualTableSelectCell extends Component {
}
VirtualTableSelectCell.propTypes = {
className: PropTypes.string.isRequired,
inputClassName: PropTypes.string.isRequired,
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
isSelected: PropTypes.bool.isRequired,
@ -75,6 +77,7 @@ VirtualTableSelectCell.propTypes = {
};
VirtualTableSelectCell.defaultProps = {
className: styles.cell,
inputClassName: styles.input,
isSelected: false
};

View File

@ -7,8 +7,6 @@ import { WindowScroller, Grid } from 'react-virtualized';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import styles from './VirtualTable.css';
const ROW_HEIGHT = 38;
function overscanIndicesGetter(options) {
const {
cellCount,
@ -48,7 +46,8 @@ class VirtualTable extends Component {
componentDidUpdate(prevProps, prevState) {
const {
items,
scrollIndex
scrollIndex,
onRecompute
} = this.props;
const {
@ -58,6 +57,7 @@ class VirtualTable extends Component {
if (this._grid &&
(prevState.width !== width ||
hasDifferentItemsOrOrder(prevProps.items, items))) {
onRecompute(width);
// recomputeGridSize also forces Grid to discard its cache of rendered cells
this._grid.recomputeGridSize();
}
@ -97,6 +97,7 @@ class VirtualTable extends Component {
scroller,
header,
headerHeight,
rowHeight,
rowRenderer,
...otherProps
} = this.props;
@ -146,7 +147,7 @@ class VirtualTable extends Component {
width={width}
height={height}
headerHeight={height - headerHeight}
rowHeight={ROW_HEIGHT}
rowHeight={rowHeight}
rowCount={items.length}
columnCount={1}
columnWidth={width}
@ -181,12 +182,16 @@ VirtualTable.propTypes = {
scroller: PropTypes.instanceOf(Element).isRequired,
header: PropTypes.node.isRequired,
headerHeight: PropTypes.number.isRequired,
rowRenderer: PropTypes.func.isRequired
rowHeight: PropTypes.oneOfType([PropTypes.func, PropTypes.number]).isRequired,
rowRenderer: PropTypes.func.isRequired,
onRecompute: PropTypes.func.isRequired
};
VirtualTable.defaultProps = {
className: styles.tableContainer,
headerHeight: 38
headerHeight: 38,
rowHeight: 38,
onRecompute: () => {}
};
export default VirtualTable;