Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: develop grid ui for song items #204

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
GITHUB_URL=https://api.github.com/
GITHUB_URL=https://api.github.com/
ITUNES_URL=https://itunes.apple.com/
3 changes: 2 additions & 1 deletion .env.development
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
GITHUB_URL=https://api.github.com/
GITHUB_URL=https://api.github.com/
ITUNES_URL=https://itunes.apple.com/
3 changes: 2 additions & 1 deletion .env.local
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
GITHUB_URL=https://api.github.com/
IS_LOCAL=true
IS_LOCAL=true
ITUNES_URL=https://itunes.apple.com/
71 changes: 71 additions & 0 deletions app/components/MediaItemCard/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from 'react';
import styled from '@emotion/styled';
import { string } from 'prop-types';
import { If } from '../If/index';

const Card = styled.div`
border-style: solid;
border-width: 1px;
padding: 5px;
border-radius: 5px;

&:hover {
cursor: pointer;
background: #eaeaea;
}

&:active {
background: #cacaca;
}
`;

/**
* MediaItemCard component that displays information about a Media item (like a song).
* It shows the repository's name, full name, and star count.
*
* @date 01/03/2024 - 14:47:28
*
* @param {Object} props - The component props.
* @param {string} props.trackName - The name of the track.
* @param {string} props.collectionName - The name of the collection.
* @param {string} props.artistName - The name of the artist.
* @param {string} props.country - The name of the country the media is from
* @param {string} props.primaryGenreName - Genre name
* @param {string} props.thumbnailSrc - thumbnail of the media
* @returns {JSX.Element} The RepoCard component displaying the repository information.
*/
export function MediaItemCard({ trackName, collectionName, artistName, country, primaryGenreName, thumbnailSrc }) {
return (
<Card>
<If condition={thumbnailSrc}>
<img src={thumbnailSrc} alt="thumbnail" />
</If>
<If condition={trackName}>
<div>{trackName}</div>
</If>
<If condition={collectionName}>
<div>{collectionName}</div>
</If>
<If condition={artistName}>
<div>{artistName}</div>
</If>
<If condition={country}>
<div>{country}</div>
</If>
<If condition={primaryGenreName}>
<div>{primaryGenreName}</div>
</If>
</Card>
);
}

MediaItemCard.propTypes = {
trackName: string,
collectionName: string,
artistName: string,
country: string,
primaryGenreName: string,
thumbnailSrc: string
};

export default MediaItemCard;
157 changes: 157 additions & 0 deletions app/containers/SongsContainer/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import React, { memo, useEffect } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { compose } from 'redux';
import { createStructuredSelector } from 'reselect';
import { injectSaga } from 'redux-injectors';
import styled from '@emotion/styled';
import { IconButton, InputAdornment, OutlinedInput } from '@mui/material';
import { Search as SearchIcon } from '@mui/icons-material';
import isEmpty from 'lodash/isEmpty';
import debounce from 'lodash/debounce';
import { translate } from '@app/utils';
import { selectLoading, selectSongName, selectSongsData, selectSongsError } from './selectors';
import { songsContainerCreators } from './reducer';
import songsContainerSaga from './saga';
import { If } from '@app/components/If/index';
import { For } from '@app/components/For/index';
import { MediaItemCard } from '@app/components/MediaItemCard/index';

const StyledOutlinedInput = styled(OutlinedInput)`
legend {
display: none;
}
> fieldset {
top: 0;
}
`;

const Grid = styled.div`
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
`;

/**
* SongsContainer component is responsible for fetching and displaying song data fetched from itunes
* @returns {JSX.Element} The SongsContainer component
*/
export function SongsContainer({
dispatchRequestGetItuneSongs,
dispatchClearItuneSongs,
intl,
songsData,
songsError,
songName,
history,
loading
}) {
useEffect(() => {
if (songName && songsData?.results?.length === 0) {
dispatchRequestGetItuneSongs(songName);
}
}, []);

const searchSongName = (sName) => {
dispatchRequestGetItuneSongs(sName);
};

const handleOnChange = async (sName) => {
if (!isEmpty(sName)) {
searchSongName(sName);
} else {
dispatchClearItuneSongs();
}
};

const debouncedHandleOnChange = debounce(handleOnChange, 200);

const handleOnSearchIconClick = () => {
searchSongName(songName);
};
return (
<>
<StyledOutlinedInput
inputProps={{ 'data-testid': 'search-bar' }}
onChange={(event) => debouncedHandleOnChange(event.target.value)}
fullWidth
defaultValue={songName}
placeholder={translate('songs_search_input_placeholder')}
endAdornment={
<InputAdornment position="end">
<IconButton
data-testid="search-icon"
aria-label="search repos"
type="button"
onClick={handleOnSearchIconClick}
>
<SearchIcon />
</IconButton>
</InputAdornment>
}
/>
<If condition={songsData?.results?.length > 0}>
<For
of={songsData.results}
ParentComponent={Grid}
renderItem={(item, index) => {
return (
<MediaItemCard
key={index}
trackName={item?.trackName}
collectionName={item?.collectionName}
artistName={item?.artistName}
country={item?.country}
primaryGenreName={item?.primaryGenreName}
thumbnailSrc={item?.artworkUrl100}
/>
);
}}
/>
</If>
</>
);
}

SongsContainer.propTypes = {
dispatchRequestGetItuneSongs: PropTypes.func,
dispatchClearItuneSongs: PropTypes.func,
intl: PropTypes.object,
songsData: PropTypes.shape({
resultCount: PropTypes.number,
results: PropTypes.array
}),
songsError: PropTypes.string,
songName: PropTypes.string,
history: PropTypes.object,
loading: PropTypes.bool
};

SongsContainer.defaultProps = {
songsData: {},
songsError: null
};

const mapStateToProps = createStructuredSelector({
loading: selectLoading(),
songsData: selectSongsData(),
songsError: selectSongsError(),
songName: selectSongName()
});

// eslint-disable-next-line require-jsdoc
export function mapDispatchToProps(dispatch) {
const { requestGetItuneSongs, clearItuneSongs } = songsContainerCreators;
return {
dispatchRequestGetItuneSongs: (songName) => dispatch(requestGetItuneSongs(songName)),
dispatchClearItuneSongs: () => dispatch(clearItuneSongs())
};
}

const withConnect = connect(mapStateToProps, mapDispatchToProps);

export default compose(
withConnect,
memo,
injectSaga({ key: 'songsContainer', saga: songsContainerSaga })
)(SongsContainer);
7 changes: 7 additions & 0 deletions app/containers/SongsContainer/loadable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Asynchronously loads the component for NotFoundPage
*/

import loadable from '@utils/loadable';

export default loadable(() => import('./index'));
44 changes: 44 additions & 0 deletions app/containers/SongsContainer/reducer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
*
* HomeContainer reducer
*
*/
import { produce } from 'immer';
import { createActions } from 'reduxsauce';
import get from 'lodash/get';

export const { Types: songsContainerTypes, Creators: songsContainerCreators } = createActions({
requestGetItuneSongs: ['songName'],
successGetItuneSongs: ['data'],
failureGetItuneSongs: ['error'],
clearItuneSongs: {}
});
export const initialState = { songName: null, songsData: {}, songsError: null, loading: null };

export const songsContainerReducer = (state = initialState, action) =>
produce(state, (draft) => {
switch (action.type) {
case songsContainerTypes.REQUEST_GET_ITUNE_SONGS:
draft.songName = action.songName;
draft.loading = true;
break;
case songsContainerTypes.CLEAR_ITUNE_SONGS:
draft.songName = null;
draft.songsError = null;
draft.songsData = {};
draft.loading = null;
break;
case songsContainerTypes.SUCCESS_GET_ITUNE_SONGS:
draft.songsData = action.data;
draft.songsError = null;
draft.loading = false;
break;
case songsContainerTypes.FAILURE_GET_ITUNE_SONGS:
draft.songsError = get(action.error, 'message', 'something_went_wrong');
draft.songsData = null;
draft.loading = false;
break;
}
});

export default songsContainerReducer;
37 changes: 37 additions & 0 deletions app/containers/SongsContainer/saga.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { put, call, takeLatest } from 'redux-saga/effects';
import { songsContainerCreators, songsContainerTypes } from './reducer';
import { getSongs } from '@services/songApi';

const { REQUEST_GET_ITUNE_SONGS } = songsContainerTypes;
const { successGetItuneSongs, failureGetItuneSongs } = songsContainerCreators;

/**
* A saga that handles fetching GitHub repositories based on a given repository name.
* On success, it dispatches a success action with the fetched data.
* On failure, it dispatches a failure action with the error data.
*
* @date 01/03/2024 - 14:47:28
*
* @param {Object} action - The action object containing the repository name.
* @yields {Effect} The effect of calling the API, and then either the success or failure action.
*/
export function* getItuneSongs(action) {
const response = yield call(getSongs, action.songName);
const { data, ok } = response;
if (ok) {
yield put(successGetItuneSongs(data));
} else {
yield put(failureGetItuneSongs(data));
}
}

/**
* registering events
* @date 04/03/2024 - 12:57:49
*
* @export
* @returns {{}}
*/
export default function* songsContainerSaga() {
yield takeLatest(REQUEST_GET_ITUNE_SONGS, getItuneSongs);
}
27 changes: 27 additions & 0 deletions app/containers/SongsContainer/selectors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createSelector } from 'reselect';
import get from 'lodash/get';
import { initialState } from './reducer';

/**
* Direct selector to the homeContainer state domain
*/

export const selectSongsContainerDomain = (state) => state.songsContainer || initialState;

/**
* Other specific selectors
*/

/**
* Default selector used by HomeContainer
*/

export const selectSongsData = () =>
createSelector(selectSongsContainerDomain, (substate) => get(substate, 'songsData'));

export const selectLoading = () => createSelector(selectSongsContainerDomain, (substate) => get(substate, 'loading'));

export const selectSongsError = () =>
createSelector(selectSongsContainerDomain, (substate) => get(substate, 'songsError'));

export const selectSongName = () => createSelector(selectSongsContainerDomain, (substate) => get(substate, 'songName'));
4 changes: 3 additions & 1 deletion app/create-root-reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { combineReducers } from 'redux';
import LanguageProviderReducer from '@containers/LanguageProvider/reducer';
import HomeContainerReducer from '@containers/HomeContainer/reducer';
import SongsContainerReducer from './containers/SongsContainer/reducer';

/**
* Merges the main reducer with the router state and dynamically injected reducers
Expand All @@ -13,6 +14,7 @@ export default function createRootReducer(injectedReducer = {}) {
return combineReducers({
...injectedReducer,
language: LanguageProviderReducer,
homeContainer: HomeContainerReducer
homeContainer: HomeContainerReducer,
songsContainer: SongsContainerReducer
});
}
Loading
Loading