« Previous Tutorial Next Tutorial »

We’re going to bang out a whole bunch of code in this tutorial, and we’re going to do it fast, because it’s a whole lot of duplicate functionality, some of which is just slightly modified. I’m going to roll through this tutorial like a boulder chasing Indiana Jones.

Ready? Let’s get artist searching and adding working! In /routes/api create a new file called artists.js. This is going to be a lot like the albums route, but slightly simplified because when a user adds an artist, we don’t have to loop through all that artist’s albums, because obviously you can be a fan of an artist without owning every single one of their releases.

Start with imports and setup. Here’s the code:

const Artist = require('../../models/artist.js');
const appConfig = require('../../config.js');
const Discogs = require('disconnect').Client;
const express = require('express');
const mongoose = require('mongoose');
const User = require('../../models/user.js');

const router = express.Router();

// configure mongoose promises
mongoose.Promise = global.Promise;

// configure Discogs
const discogsClient = new Discogs('MusicList-closebrace/0.1', {
  consumerKey: appConfig.discogs.key,
  consumerSecret: appConfig.discogs.secret,
});
const discogsDB = discogsClient.database();

Then we need a saveArtist helper. Here it is:

// Check if artist exists and if not, save it
const saveArtist = async (artistInfo) => {
  let errors = false;
  const artistQuery = await Artist.findOne({ discogsId: artistInfo.id });
  if (!artistQuery) {
    const artistInfoModified = Object.assign({ discogsId: artistInfo.id }, artistInfo);
    const newArtist = new Artist(artistInfoModified);
    await newArtist.save((error) => {
      if (error) { errors = true; }
    });
  }
  if (errors) {
    return false;
  }
  return true;
};

Follow that up with an addArtist function, like this:

// POST to /add
router.post('/add', async (req, res) => {
  // Make sure a user's actually logged in
  if (!req.user) {
    return res.json({ error: 'User not logged in' });
  }

  // Get a single artist from Discogs
  const discogsGetArtist = artistId => new Promise((resolve) => {
    discogsDB.getArtist(artistId, (err, data) => {
      resolve(data);
    });
  });

  const artistId = parseInt(req.body.id, 10);
  let result;

  try {
    // Get artist info from discogs AI
    const artistInfo = await discogsGetArtist(artistId);

    // Save it to the MusicList DB if it's not already there
    const artistSaved = await saveArtist(artistInfo);
    if (!artistSaved) { return JSON.stringify(new Error('There was a problem saving the artist to the database.')); }

    // Find the user we want to save to
    const query = User.findOne({ email: req.user.email });
    const foundUser = await query.exec();

    // Sanity Check! Is the artist already added?
    const artistIndex = foundUser.artists.indexOf(artistInfo.id);
    if (artistIndex < 0) {
      foundUser.artists.push(artistInfo.id);
    }

    foundUser.save((error) => {
      if (error) {
        result = res.json({ error: 'Artist could not be saved. Please try again.' });
      } else {
        result = res.json(foundUser);
      }
    });
  } catch (err) {
    result = res.json({ error: 'There was an error saving the artist to the database. Please try again.' });
  }

  return result;
});

And finally, our search route and export:

// POST to /search
router.post('/search', async (req, res) => {
  // Contact Discogs API
  await discogsDB.search(req.body, (err, data) => {
    if (err) {
      const error = new Error(err);
      return res.json(error);
    }
    return res.json(data);
  });
});

module.exports = router;

Total line count is 99, including the padding line at the end of the file. Save the file, and open app.js. Below line 26, add this code:

const artists = require('./routes/api/artists');

And below line 69, add this:

app.use('/api/artists', artists);

That’s it. Save the file, and let’s create a file in /src/components/actions called artists.js. This one’s near-identical to albums.js except that every instance of “album” has been replaced with “artist”. Here are our imports and action creators:

import 'whatwg-fetch';
import { decrementProgress, incrementProgress } from './progress';
import { clearError } from './error';

// Action Creators
export const artistAddFailure = error => ({ type: 'MUSIC_ARTIST_ADD_FAILURE', error });
export const artistAddSuccess = json => ({ type: 'MUSIC_ARTIST_ADD_SUCCESS', json });
export const artistSearchClear = () => ({ type: 'MUSIC_ARTIST_SEARCH_CLEAR' });
export const artistSearchFailure = error => ({ type: 'MUSIC_ARTIST_SEARCH_FAILURE', error });
export const artistSearchSuccess = json => ({ type: 'MUSIC_ARTIST_SEARCH_SUCCESS', json });

And here’s our addArtist function:

// Add an Artist
export function addArtist(id) {
  return async (dispatch) => {
    // clear the error box if it's displayed
    dispatch(clearError());

    // turn on spinner
    dispatch(incrementProgress());

    // Send packet to our API, which will communicate with Discogs
    await fetch(
      // where to contact
      '/api/artists/add',
      // what to send
      {
        method: 'POST',
        body: JSON.stringify({ id }),
        headers: {
          'Content-Type': 'application/json',
        },
        credentials: 'same-origin',
      },
    )
    .then((response) => {
      if (response.status === 200) {
        return response.json();
      }
      return null;
    })
    .then((json) => {
      if (json.email) {
        return dispatch(artistAddSuccess(json));
      }
      return dispatch(artistAddFailure(new Error(json.error)));
    })
    .catch(error => dispatch(artistAddFailure(new Error(error))));

    // turn off spinner
    return dispatch(decrementProgress());
  };
}

And finally our searchArtists function:

// Search Artists
export function searchArtists(searchText) {
  return async (dispatch) => {
    // clear the error box if it's displayed
    dispatch(clearError());


    dispatch(incrementProgress());

    // Build packet to send to Discogs API
    const searchQuery = {
      q: searchText,
      type: 'artist',
    };

    // Send packet to our API, which will communicate with Discogs
    await fetch(
      // where to contact
      '/api/artists/search',
      // what to send
      {
        method: 'POST',
        body: JSON.stringify(searchQuery),
        headers: {
          'Content-Type': 'application/json',
        },
        credentials: 'same-origin',
      },
    )
    .then((response) => {
      if (response.status === 200) {
        return response.json();
      }
      return null;
    })
    .then((json) => {
      if (json.results) {
        return dispatch(artistSearchSuccess(json));
      }
      return dispatch(artistSearchFailure(new Error(json.error)));
    })
    .catch(error => dispatch(artistSearchFailure(new Error(error))));

    // turn off spinner
    return dispatch(decrementProgress());
  };
}

Total line count, including the padding line at the end, should be 101. We’re good here. You should have a firm grasp of what this stuff is doing by now. If something’s confusing, drop me a line and we’ll figure it out! Save the file, and move on to /src/reducers where we’ll create a new file called artists.js. Here’s all of the code:

const initialState = [];
export default function reducer(state = initialState, action) {
  switch (action.type) {
    case 'MUSIC_ARTIST_SEARCH_SUCCESS': {
      const newState = action.json.results.slice();
      return newState;
    }
    case 'MUSIC_ARTIST_SEARCH_CLEAR': {
      const newState = initialState.slice();
      return newState;
    }
    default: {
      return state;
    }
  }
}

WHAM! That’s it for this one, so save it, and open up /src/reducers/user.js. We’re just adding a new case block, here, so below line 19, add this code:

    case 'MUSIC_ARTIST_ADD_SUCCESS': {
      const newState = Object.assign({ }, state);
      newState.artists = action.json.artists;
      return newState;
    }

Then save that file, because you’re sooo done with it. Open up /src/reducers/error.js and let’s make sure we’re reporting errors well. We actually forgot this step with album addition, so we’ll fix that here too. Replace line 13 with the following four lines:

    case 'MUSIC_ALBUM_ADD_FAILURE':
    case 'MUSIC_ALBUM_SEARCH_FAILURE':
    case 'MUSIC_ARTIST_ADD_FAILURE':
    case 'MUSIC_ARTIST_SEARCH_FAILURE': {

Now when something breaks, we won’t have to head for the console to at least get an idea of what’s going on. Save this file, and open /src/reducers/index.js. We need to add our artists reducer to the store, so below line 2 add this code:

import ArtistsReducer from '../reducers/artists';

And below line 10 add this:

  artists: ArtistsReducer,

You’re good here, so save the file. Create a new folder in /src/components called artists and then within that, a new file called ArtistsPageContainer.jsx. This is going to look a whole lot like AlbumsPageContainer.jsx … you may notice a theme, here. This is all of the code:

import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { addArtist, artistSearchClear, searchArtists } from '../../actions/artists';

import ArtistsPage from './ArtistsPage';

export class ArtistsPageContainer extends React.Component {

  componentWillUnmount() {
    const { dispatch } = this.props;
    dispatch(artistSearchClear());
  }

  render() {
    const { addArtistFunction, artists, searchArtistsFunction, user } = this.props;
    return (
      <ArtistsPage
        addArtistFunction={addArtistFunction}
        artists={artists}
        searchArtistsFunction={searchArtistsFunction}
        user={user}
      />
    );
  }
}

const mapDispatchToProps = dispatch => bindActionCreators({
  addArtistFunction: addArtist,
  searchArtistsFunction: searchArtists,
  dispatch,
}, dispatch);
const mapStateToProps = state => ({ artists: state.artists, user: state.user });

export default connect(mapStateToProps, mapDispatchToProps)(ArtistsPageContainer);

Now save that and create a file in /src/components/artists called ArtistsPage.jsx. There are some changes here because it’s a slightly simpler set of data to display. We don’t need to loop through genres, and we don’t need to split up the “title” of the artist (it’s still called that even though what it means is “name”.

Here are our imports and the opening of our class:

import React from 'react';
import { AvForm, AvGroup, AvInput, AvFeedback } from 'availity-reactstrap-validation';
import { Button, Label, Table } from 'reactstrap';

export default class ArtistsPage extends React.Component {
  constructor(props) {
    super(props);

    // bound functions
    this.addArtist = this.addArtist.bind(this);
    this.createTable = this.createTable.bind(this);
    this.handleSearchChange = this.handleSearchChange.bind(this);
    this.handleKeyPress = this.handleKeyPress.bind(this);
    this.handleValidSubmit = this.handleValidSubmit.bind(this);
    this.listArtists = this.listArtists.bind(this);

    // component state
    this.state = {
      searchText: '',
    };
  }

Then we need our three handle methods:

  // update state as search value changes
  handleSearchChange(e) {
    this.setState({ searchText: e.target.value });
  }

  // catch enter clicks
  handleKeyPress(target) {
    if (target.charCode === 13) {
      this.handleValidSubmit();
    }
  }

  // Handle submission once all form data is valid
  handleValidSubmit() {
    const { searchArtistsFunction } = this.props;
    const formData = this.state;
    searchArtistsFunction(formData.searchText);
  }

And our table creation methods:

  createTable(artists) {
    return (
      <Table striped responsive>
        <thead>
          <tr>
            <th />
            <th>Artist</th>
            <th />
          </tr>
        </thead>
        <tbody>
          { this.listArtists(artists) }
        </tbody>
      </Table>
    );
  }

  listArtists(artists) {
    const { user } = this.props;
    return artists.map(artist =>
      (
        <tr key={artist.id}>
          <td><img src={artist.thumb} alt="artist thumbnail" width="80" height="80" /></td>
          <td>{artist.title}</td>
          <td>
            { user.artists.indexOf(artist.id) < 0 ?
              <Button color="primary" outline id={artist.id} onClick={this.addArtist}>
                Add To My List
              </Button> :
              <span>Already Listed</span>
            }
          </td>
        </tr>
      ));
  }

Then there’s our addArtist method:

  // Add an artist to the user's list
  addArtist(e) {
    const { addArtistFunction } = this.props;
    // get id from button and send to the API
    addArtistFunction(e.target.id);
  }

And finally our render block:

  render() {
    const { artists } = this.props;
    return (
      <div>
        <div className="row justify-content-center">
          <div className="col-10 col-sm-7 col-md-5 col-lg-4">
            <AvForm onValidSubmit={this.handleValidSubmit}>
              <AvGroup>
                <h2><Label for="search">Search Artists</Label></h2>
                <p>
                  Find artists you own and add them to your MusicList.
                </p>
                <AvInput
                  id="search"
                  name="search"
                  onChange={this.handleSearchChange}
                  onKeyPress={this.handleKeyPress}
                  placeholder="Lorde"
                  required
                  type="text"
                  value={this.state.searchText}
                />
                <AvFeedback>Required</AvFeedback>
              </AvGroup>
              <Button color="primary">Search Artists</Button>
            </AvForm>
          </div>
        </div>
        <div className="row">
          <div className="col-12 col-sm-12">
            { artists && artists.length > 0 ? <h2>Artists</h2> : null }
            <div className="row">
              <div className="col-sm-12 col-lg-12">
                { artists && artists.length > 0 ? this.createTable(artists) : null }
              </div>
            </div>
          </div>
        </div>
      </div>
    );
  }
}

You may be wondering at this point why we’re creating so much code that shares so many similarities instead of generalizing a lot of it into shared functions. That’s a great question! The answer is “because this tutorial series already has 72 entries and it has to end sometime.” In a real production app, which would have many more features and components, trying to do this repetitive coding trick would become unmanageable very, very quickly. Thankfully, this isn’t a real production app, and we don’t actually have that much left to do (seriously!), so we’re just going to accept the redundancy. If you’d like me to do some tutorials on how to reduce redundant code down into reusable functions, I’ll be happy to do so … as a standalone set!

Anyway, one last file to edit. Save this one, and let’s tell our Template that these new pages exist. Head for /src/components/Template.jsx and, below line 4, add this code:

import ArtistsPage from './artists/ArtistsPageContainer';

Below line 31, add this code:

          <Route exact path="/artists" component={ArtistsPage} />

You’re good to go. Save, and let’s test. Head for a browser, navigate to localhost:3000, refresh the page, make sure you’re logged in, and then go to /artists. Search for an artist you like, and they should come up. Once they’re there, try adding one, and it should work just like the albums did. Success!

Next up, we’re going to spend a couple of tutorials displaying users’ lists, and allowing them to do things like removing artists / albums. See you soon!

« Previous Tutorial Next Tutorial »