diff --git a/app/components/TrackCard/index.js b/app/components/TrackCard/index.js index 38bee99..b68df6a 100644 --- a/app/components/TrackCard/index.js +++ b/app/components/TrackCard/index.js @@ -17,6 +17,7 @@ import IconButton from '@mui/material/IconButton'; import Typography from '@mui/material/Typography'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import PauseIcon from '@mui/icons-material/Pause'; +import { Link } from 'react-router-dom'; const TrackCustomCard = styled(Card)` && { @@ -70,6 +71,7 @@ const TrackPlayIcon = styled(PlayArrowIcon)` export function TrackCard({ trackPlaying, setTrackPlaying, + collectionId, collectionName, artistName, shortDescription, @@ -118,14 +120,16 @@ export function TrackCard({ - - } - > - - - + + + } + > + + + + should render and match the snapshot 1`] = `
-
-

- Track name is unavailable -

-
+

+ Track name is unavailable +

+
+
', () => { it('should render and match the snapshot', () => { const mockSetTrackPlaying = jest.fn(); - const { baseElement } = renderWithIntl(); + const { baseElement } = renderWithIntl( + + + + ); expect(baseElement).toMatchSnapshot(); }); it('should contain 1 TrackCard component', () => { const mockSetTrackPlaying = jest.fn(); - const { getAllByTestId } = renderWithIntl(); + const { getAllByTestId } = renderWithIntl( + + + + ); expect(getAllByTestId('track-card').length).toBe(1); }); @@ -31,7 +40,11 @@ describe('', () => { artworkUrl100: 'https://example.com/image.jpg', previewUrl: 'https://example.com/preview.mp3' }; - const { getByTestId } = renderWithIntl(); + const { getByTestId } = renderWithIntl( + + + + ); expect(getByTestId('collectionName')).toHaveTextContent(trackData.collectionName); expect(getByTestId('artistName')).toHaveTextContent(trackData.artistName); @@ -44,7 +57,11 @@ describe('', () => { const artistNameUnavailable = translate('track_artist_name_unavailable'); const shortDescriptionUnavailable = translate('track_shortdesc_unavailable'); - const { getByTestId } = renderWithIntl(); + const { getByTestId } = renderWithIntl( + + + + ); expect(getByTestId('collection_name_unavailable')).toHaveTextContent(collectionNameUnavailable); expect(getByTestId('track_artist_name_unavailable')).toHaveTextContent(artistNameUnavailable); expect(getByTestId('track_shortdesc_unavailable')).toHaveTextContent(shortDescriptionUnavailable); diff --git a/app/containers/TrackDetailContainer/Loadable.js b/app/containers/TrackDetailContainer/Loadable.js new file mode 100644 index 0000000..088450c --- /dev/null +++ b/app/containers/TrackDetailContainer/Loadable.js @@ -0,0 +1,9 @@ +/** + * + * Asynchronously loads the component for TrackDetailContainer + * + */ + +import loadable from 'utils/loadable'; + +export default loadable(() => import('./index')); diff --git a/app/containers/TrackDetailContainer/index.js b/app/containers/TrackDetailContainer/index.js new file mode 100644 index 0000000..cf9273c --- /dev/null +++ b/app/containers/TrackDetailContainer/index.js @@ -0,0 +1,142 @@ +import React, { memo, useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { createStructuredSelector } from 'reselect'; +import { compose } from 'redux'; +import { useParams } from 'react-router-dom'; +import isEmpty from 'lodash/isEmpty'; +import styled from '@emotion/styled'; +import { trackDetailContainerCreators } from './reducer'; +import { injectSaga } from 'redux-injectors'; +import { Card, CardHeader, Container, Divider, Skeleton } from '@mui/material'; +import TrackCard from '@components/TrackCard'; +import If from '@components/If'; +import T from '@components/T'; +import get from 'lodash/get'; +import trackDetailContainerSaga from './saga'; +import { selectTrackDetailData, selectTrackError, selectTrackDetailLoading } from './selectors'; +import { translate } from '@app/utils/index'; + +// Custom Styling +const CustomCard = styled(Card)` + && { + margin: 1.25rem 0; + padding: 1rem; + max-width: ${(props) => props.maxwidth}; + color: ${(props) => props.color}; + ${(props) => props.color && `color: ${props.color}`}; + } +`; +const CustomCardHeader = styled(CardHeader)` + && { + padding: 0; + } +`; + +export const TrackDetailContainer = ({ + trackDetailData, + trackError, + trackDetailLoading, + dispatchTrackDetail, + padding +}) => { + const { trackId } = useParams(); + + useEffect(() => { + dispatchTrackDetail(trackId); + }, []); + + const renderSkeleton = () => { + return ( + <> + + + + + ); + }; + + const renderTrackDetail = () => { + const trackData = get(trackDetailData, 'results', [])[0]; + const [trackPlaying, setTrackPlaying] = useState(''); + + return ( + <> + + + + + + ); + }; + + const renderErrorState = () => { + let trackDetailError; + if (trackError) { + trackDetailError = trackError; + } + return ( + !trackDetailLoading && + trackDetailError && ( + + + + }> + + + + ) + ); + }; + + return ( + + + {renderTrackDetail()} + + {renderErrorState()} + + ); +}; + +TrackDetailContainer.propTypes = { + trackDetailData: PropTypes.shape({ + resultCount: PropTypes.number, + results: PropTypes.array + }), + trackError: PropTypes.string, + trackDetailLoading: PropTypes.bool, + dispatchTrackDetail: PropTypes.func, + padding: PropTypes.number +}; + +TrackDetailContainer.defaultProps = { + trackDetailData: {}, + trackError: null, + trackDetailLoading: false +}; + +const mapStateToProps = createStructuredSelector({ + trackDetailData: selectTrackDetailData(), + trackError: selectTrackError(), + trackDetailLoading: selectTrackDetailLoading() +}); + +export function mapDispatchToProps(dispatch) { + const { requestGetTrackDetail } = trackDetailContainerCreators; + return { + dispatchTrackDetail: (trackId) => dispatch(requestGetTrackDetail(trackId)) + }; +} + +const withConnect = connect(mapStateToProps, mapDispatchToProps); + +export default compose( + withConnect, + memo, + injectSaga({ key: 'trackDetailContainer', saga: trackDetailContainerSaga }) +)(TrackDetailContainer); + +export const TrackDetailContainerTest = compose()(TrackDetailContainer); diff --git a/app/containers/TrackDetailContainer/reducer.js b/app/containers/TrackDetailContainer/reducer.js new file mode 100644 index 0000000..4de26ce --- /dev/null +++ b/app/containers/TrackDetailContainer/reducer.js @@ -0,0 +1,36 @@ +/* + * + * TrackDetailContainer reducer + * + */ +import produce from 'immer'; +import { createActions } from 'reduxsauce'; +import get from 'lodash/get'; + +export const { Types: trackDetailContainerTypes, Creators: trackDetailContainerCreators } = createActions({ + requestGetTrackDetail: ['trackId'], + successGetTrackDetail: ['data'], + failureGetTrackDetail: ['error'] +}); +export const initialState = { trackId: null, trackDetailData: {}, trackError: null, trackDetailLoading: false }; + +/* eslint-disable default-case, no-param-reassign */ +export const trackDetailContainerReducer = (state = initialState, action) => + produce(state, (draft) => { + switch (action.type) { + case trackDetailContainerTypes.REQUEST_GET_TRACK_DETAIL: + draft.trackId = action.trackId; + draft.trackDetailLoading = true; + break; + case trackDetailContainerTypes.SUCCESS_GET_TRACK_DETAIL: + draft.trackDetailData = action.data; + draft.trackDetailLoading = false; + break; + case trackDetailContainerTypes.FAILURE_GET_TRACK_DETAIL: + draft.trackError = get(action.error, 'message', 'something_went_wrong'); + draft.trackDetailLoading = false; + break; + } + }); + +export default trackDetailContainerReducer; diff --git a/app/containers/TrackDetailContainer/saga.js b/app/containers/TrackDetailContainer/saga.js new file mode 100644 index 0000000..9eb254c --- /dev/null +++ b/app/containers/TrackDetailContainer/saga.js @@ -0,0 +1,19 @@ +import { put, call, takeLatest } from 'redux-saga/effects'; +import { getTrack } from '@services/trackApi'; +import { trackDetailContainerTypes, trackDetailContainerCreators } from './reducer'; + +const { REQUEST_GET_TRACK_DETAIL } = trackDetailContainerTypes; +const { successGetTrackDetail, failureGetTrackDetail } = trackDetailContainerCreators; +export function* getTrackDetail(action) { + const response = yield call(getTrack, action.trackId); + const { data, ok } = response; + if (ok) { + yield put(successGetTrackDetail(data)); + } else { + yield put(failureGetTrackDetail(data)); + } +} +// Individual exports for testing +export default function* trackDetailContainerSaga() { + yield takeLatest(REQUEST_GET_TRACK_DETAIL, getTrackDetail); +} diff --git a/app/containers/TrackDetailContainer/selectors.js b/app/containers/TrackDetailContainer/selectors.js new file mode 100644 index 0000000..6df5551 --- /dev/null +++ b/app/containers/TrackDetailContainer/selectors.js @@ -0,0 +1,26 @@ +import { createSelector } from 'reselect'; +import get from 'lodash/get'; +import { initialState } from './reducer'; + +/** + * Direct selector to the trackDetailContainer state domain + */ + +export const selectTrackDetailContainerDomain = (state) => state.trackDetailContainer || initialState; + +/** + * Other specific selectors + */ + +/** + * Default selector used by trackDetailContainer + */ + +export const selectTrackDetailData = () => + createSelector(selectTrackDetailContainerDomain, (substate) => get(substate, 'trackDetailData')); + +export const selectTrackError = () => + createSelector(selectTrackDetailContainerDomain, (substate) => get(substate, 'trackError')); + +export const selectTrackDetailLoading = () => + createSelector(selectTrackDetailContainerDomain, (substate) => get(substate, 'trackDetailLoading')); diff --git a/app/containers/TrackDetailContainer/tests/__snapshots__/index.test.js.snap b/app/containers/TrackDetailContainer/tests/__snapshots__/index.test.js.snap new file mode 100644 index 0000000..514a270 --- /dev/null +++ b/app/containers/TrackDetailContainer/tests/__snapshots__/index.test.js.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` tests should render and match the snapshot 1`] = ` + +
+
+
+
+ +
+

+ Artist name is unavailable +

+
+

+ Description unavaiable +

+
+
+
+
+ +`; diff --git a/app/containers/TrackDetailContainer/tests/index.test.js b/app/containers/TrackDetailContainer/tests/index.test.js new file mode 100644 index 0000000..4349e8f --- /dev/null +++ b/app/containers/TrackDetailContainer/tests/index.test.js @@ -0,0 +1,44 @@ +/** + * + * Tests for TrackDetailContainer + * + */ + +import React from 'react'; +import { renderProvider } from '@utils/testUtils'; +import { TrackDetailContainerTest as TrackDetailContainer, mapDispatchToProps } from '../index'; +import { trackDetailContainerTypes } from '../reducer'; +import { translate } from '@app/utils/index'; + +describe(' tests', () => { + let submitSpy; + + beforeEach(() => { + submitSpy = jest.fn(); + }); + it('should render and match the snapshot', () => { + const { baseElement } = renderProvider(); + expect(baseElement).toMatchSnapshot(); + }); + + it('should validate mapDispatchToProps actions', async () => { + const dispatchTracksSearchSpy = jest.fn(); + const trackId = 12323; + const actions = { + dispatchTrackDetail: { trackId, type: trackDetailContainerTypes.REQUEST_GET_TRACK_DETAIL } + }; + + const props = mapDispatchToProps(dispatchTracksSearchSpy); + props.dispatchTrackDetail(trackId); + expect(dispatchTracksSearchSpy).toHaveBeenCalledWith(actions.dispatchTrackDetail); + }); + + it('should render default error message when search goes wrong', () => { + const defaultError = translate('something_went_wrong'); + const { getByTestId } = renderProvider( + + ); + expect(getByTestId('error-message')).toBeInTheDocument(); + expect(getByTestId('error-message').textContent).toBe(defaultError); + }); +}); diff --git a/app/containers/TrackDetailContainer/tests/reducer.test.js b/app/containers/TrackDetailContainer/tests/reducer.test.js new file mode 100644 index 0000000..56798ca --- /dev/null +++ b/app/containers/TrackDetailContainer/tests/reducer.test.js @@ -0,0 +1,46 @@ +import { trackDetailContainerReducer, initialState, trackDetailContainerTypes } from '../reducer'; + +/* eslint-disable default-case, no-param-reassign */ +describe('TrackDetailContainer reducer tests', () => { + let state; + beforeEach(() => { + state = initialState; + }); + + it('should return the initial state', () => { + expect(trackDetailContainerReducer(undefined, {})).toEqual(state); + }); + + it('should return the initial state when an action of type REQUEST_GET_TRACK_DETAIL is dispatched', () => { + const trackId = 'Sunflower'; + const expectedResult = { ...state, trackId, trackDetailLoading: true }; + expect( + trackDetailContainerReducer(state, { + type: trackDetailContainerTypes.REQUEST_GET_TRACK_DETAIL, + trackId + }) + ).toEqual(expectedResult); + }); + + it('should ensure that the user data is present and loading = false when SUCCESS_GET_TRACK_DETAIL is dispatched', () => { + const data = { artistName: 'Post Malone' }; + const expectedResult = { ...state, trackDetailData: data, trackDetailLoading: false }; + expect( + trackDetailContainerReducer(state, { + type: trackDetailContainerTypes.SUCCESS_GET_TRACK_DETAIL, + data + }) + ).toEqual(expectedResult); + }); + + it('should ensure that the userErrorMessage has some data and loading = false when FAILURE_GET_ITUNES_TRACKS is dispatched', () => { + const error = 'something_went_wrong'; + const expectedResult = { ...state, trackError: error, trackDetailLoading: false }; + expect( + trackDetailContainerReducer(state, { + type: trackDetailContainerTypes.FAILURE_GET_TRACK_DETAIL, + error + }) + ).toEqual(expectedResult); + }); +}); diff --git a/app/containers/TrackDetailContainer/tests/saga.test.js b/app/containers/TrackDetailContainer/tests/saga.test.js new file mode 100644 index 0000000..148f786 --- /dev/null +++ b/app/containers/TrackDetailContainer/tests/saga.test.js @@ -0,0 +1,52 @@ +/** + * Test TrackDetailContainer sagas + */ + +/* eslint-disable redux-saga/yield-effects */ +import { takeLatest, call, put } from 'redux-saga/effects'; +import { getTrack } from '@services/trackApi'; +import { apiResponseGenerator } from '@utils/testUtils'; +import trackDetailContainerSaga, { getTrackDetail } from '../saga'; +import { trackDetailContainerTypes } from '../reducer'; + +describe('TrackDetailContainer saga tests', () => { + const generator = trackDetailContainerSaga(); + const trackId = 12323; + let getTracksGenerator = getTrackDetail({ trackId }); + + it('should start task to watch for REQUEST_GET_TRACK_DETAIL action', () => { + expect(generator.next().value).toEqual( + takeLatest(trackDetailContainerTypes.REQUEST_GET_TRACK_DETAIL, getTrackDetail) + ); + }); + + it('should ensure that the action FAILURE_GET_TRACK_DETAIL is dispatched when the api call fails', () => { + const res = getTracksGenerator.next().value; + expect(res).toEqual(call(getTrack, trackId)); + const errorResponse = { + errorMessage: 'There was an error while fetching track informations.' + }; + expect(getTracksGenerator.next(apiResponseGenerator(false, errorResponse)).value).toEqual( + put({ + type: trackDetailContainerTypes.FAILURE_GET_TRACK_DETAIL, + error: errorResponse + }) + ); + }); + + it('should ensure that the action SUCCESS_GET_TRACK_DETAIL is dispatched when the api call succeeds', () => { + getTracksGenerator = getTrackDetail({ trackId }); + const res = getTracksGenerator.next().value; + expect(res).toEqual(call(getTrack, trackId)); + const tracksResponse = { + resultCount: 1, + results: [{ artistName: 'Post Malone' }] + }; + expect(getTracksGenerator.next(apiResponseGenerator(true, tracksResponse)).value).toEqual( + put({ + type: trackDetailContainerTypes.SUCCESS_GET_TRACK_DETAIL, + data: tracksResponse + }) + ); + }); +}); diff --git a/app/containers/TrackDetailContainer/tests/selectors.test.js b/app/containers/TrackDetailContainer/tests/selectors.test.js new file mode 100644 index 0000000..1457abb --- /dev/null +++ b/app/containers/TrackDetailContainer/tests/selectors.test.js @@ -0,0 +1,47 @@ +import { + selectTrackDetailContainerDomain, + selectTrackDetailData, + selectTrackError, + selectTrackDetailLoading +} from '../selectors'; +import { initialState } from '../reducer'; + +describe('TrackDetailContainer selector tests', () => { + let mockedState; + let trackDetailData; + let trackError; + let trackDetailLoading; + + beforeEach(() => { + trackDetailData = { resultCount: 1, results: [] }; + trackError = 'There was some error while fetching the track details'; + + mockedState = { + trackDetailContainer: { + trackDetailData, + trackError, + trackDetailLoading + } + }; + }); + + it('should select trackDetailData', () => { + const tracksDataSelector = selectTrackDetailData(); + expect(tracksDataSelector(mockedState)).toEqual(trackDetailData); + }); + + it('should select the trackError', () => { + const tracksErrorSelector = selectTrackError(); + expect(tracksErrorSelector(mockedState)).toEqual(trackError); + }); + + it('should select the trackDetailLoading', () => { + const tracksLoadingSelector = selectTrackDetailLoading(); + expect(tracksLoadingSelector(mockedState)).toEqual(trackDetailLoading); + }); + + it('should select the global state', () => { + const selector = selectTrackDetailContainerDomain(initialState); + expect(selector).toEqual(initialState); + }); +}); diff --git a/app/createRootReducer.js b/app/createRootReducer.js index 21bd825..76f02fc 100644 --- a/app/createRootReducer.js +++ b/app/createRootReducer.js @@ -6,6 +6,7 @@ import { combineReducers } from 'redux'; import languageProviderReducer from 'containers/LanguageProvider/reducer'; import homeContainerReducer from 'containers/HomeContainer/reducer'; import trackContainerReducer from './containers/TrackContainer/reducer'; +import trackDetailContainerReducer from './containers/TrackDetailContainer/reducer'; /** * Merges the main reducer with the router state and dynamically injected reducers @@ -15,6 +16,7 @@ export default function createRootReducer(injectedReducer = {}) { ...injectedReducer, language: languageProviderReducer, homeContainer: homeContainerReducer, - trackContainer: trackContainerReducer + trackContainer: trackContainerReducer, + trackDetailContainer: trackDetailContainerReducer }); } diff --git a/app/routeConfig.js b/app/routeConfig.js index a3875e1..d6deaa8 100644 --- a/app/routeConfig.js +++ b/app/routeConfig.js @@ -1,6 +1,7 @@ import NotFound from '@containers/NotFoundPage/Loadable'; import HomeContainer from '@containers/HomeContainer/Loadable'; import TrackContainer from '@containers/TrackContainer/Loadable'; +import TrackDetailContainer from '@containers/TrackDetailContainer/Loadable'; import routeConstants from '@utils/routeConstants'; export const routeConfig = { @@ -12,6 +13,10 @@ export const routeConfig = { component: TrackContainer, ...routeConstants.tracks }, + track: { + component: TrackDetailContainer, + ...routeConstants.track + }, notFoundPage: { component: NotFound, route: '/' diff --git a/app/services/trackApi.js b/app/services/trackApi.js index 7e1a59a..14ef737 100644 --- a/app/services/trackApi.js +++ b/app/services/trackApi.js @@ -2,3 +2,5 @@ import { generateApiClient } from '@utils/apiUtils'; const trackApi = generateApiClient('itunes'); export const getTracks = (repoName) => trackApi.get(`/search/?term=${repoName}`); + +export const getTrack = (trackId) => trackApi.get(`/lookup/?id=${trackId}`); diff --git a/app/utils/routeConstants.js b/app/utils/routeConstants.js index 868e29a..de24781 100644 --- a/app/utils/routeConstants.js +++ b/app/utils/routeConstants.js @@ -14,5 +14,13 @@ export default { padding: 20 }, exact: true + }, + track: { + route: '/tracks/:trackId', + props: { + maxwidth: 500, + padding: 20 + }, + exact: true } };