Series: Building a Social Network with Flask, React & Stream – Part 15

Spencer P.
Spencer P.
Published May 6, 2020

This article is the fourth installment of a tutorial series focused on how to create a full-stack application using Flask, React/Redux and Stream. In this article, we are going to start creating, editing, and fetching collections, or groups of links, for our web application. Be sure to check out the repo to follow along!

Getting Started

For our app, users will be uploading groups of links into objects called collections. If you remember from our last series, these collections will have a name and a description, allowing your users to separate their content into distinct groupings. For myself, I have created a collection for links from the Stream blog to see all of the exciting content that they post!

This example would be very easily adapted if you wanted to create a blog on your website where you can quickly add articles from Medium, Dev.to, etc. It would also be a snap to connect the back-end to RSS feeds to keep tabs on your favorite newspapers.

In this article, we will be looking to recreate the form from our previous vanilla HTML/CSS/JS series that looks a little like this:

Collection Agency

As always, I like by defining the requests that I will be making to the back-end. As the requests themselves are the fulcrum on which the front and back-ends communicate, writing them first allows you a great starting point to start structuring both sides.

Since we are going to be doing all the CRUD (Create, Read, Update, Delete) tasks for our collections, we will need to define these new request types in our agent constant.

After that, we will create a new ‘Collections’ constant that will hold the task-specific requests for getting, updating, creating, and deleting those collections. We will need a unique ID to retrieve and update collections, as well as a name and description variables when we create or update them (in app/static/js/agent.jsx).

#...
const agent = {
  #...
    put: (url, form) =>
      superagent.put(`${API_ROOT}${url}`).type('form').send(form).then(responseBody),
    del: url =>
      superagent.del(`${API_ROOT}${url}`).then(responseBody),
};

const Collections = {
    get: id =>
        agent.get(`/collections/${id}`),
    update: (name, description, id) =>
        agent.put(`/collections/${id}`, { name, description }),
    create: (name, description) =>
        agent.post('/collections', { name, description }),
    del: id =>
        agent.del(`/collections/${id}`)
};

export default {
    Auth,
    Profile,
    Collections
}

Collection Editor Reducer

Now that our requests are finished, we can move onto creating a reducer to deal with the state of our CollectionEditor component that we will be constructing next. In this component, we will need to handle when the page is loaded (to set the props for the name and description fields if we are updating an existing collection) as well as one to unload and erase the state. After, we will need to handle updating fields from user input, another one to handle form submission, and one for the start of the asynchronous request (in app/static/js/reducers/collectionEditor.jsx).

export default (state = {}, action) => {
    switch (action.type) {
        case 'COLLECTION_EDITOR_PAGE_LOADED':
            return {
                ...state,
                name: action.payload ? action.payload.collection.name : '',
                description: action.payload ? action.payload.collection.description: '',
            };
        case 'COLLECTION_EDITOR_PAGE_UNLOADED':
            return {};
        case 'COLLECTION_SUBMITTED':
            return {
                ...state,
                inProgress: null,
                errors: action.error ? action.payload.errors : null
            };
        case "ASYNC_START":
            if (action.subtype === 'COLLECTION_SUBMITTED') {
            return { ...state, inProgress: true };
        }
            break;
        case 'UPDATE_FIELD_COLLECTION':
            return { ...state, [action.key]: action.value };
        default:
            return state;
    }

    return state;
};


Collection Editor

With the reducer ready to go, we can create our mapStateToProps and mapDispatchToProps constants that will be connected to the CollectionEditor component. We will define our component functions for changing the collection name and description, as well as for submitting a form. Considering that the form will be used for both creating and updating existing forms, we need to include some conditionality for the request to direct it towards the proper route. Given that an existing collection will have an ID value associated while a new one will not, we can create a ternary statement dependent on the existence of that property.

Once our constructor is done, we can create a componentWillReceiveProps lifecycle method to handle when a new collection is loaded as well as a componentWillMount function that will take parameters from the URL to search for the existing collection. As a final touch, we will add another ternary operator to the button rendering to differentiate between creating and updating collections (in app/static/js/components/collection/CollectionEditor.jsx).

import React, { Component } from 'react';
import agent from '../../agent';
import { connect } from 'react-redux';

import ListErrors from '../ListErrors';

const mapStateToProps = state => ({
    ...state.collectionEditor
});

const mapDispatchToProps = dispatch => ({
    onLoad: payload =>
        dispatch({ type: 'COLLECTION_EDITOR_PAGE_LOADED', payload}),
    onSubmit: payload =>
        dispatch({ type: 'COLLECTION_SUBMITTED', payload }),
    onUnload: () =>
        dispatch({ type: 'COLLECTION_EDITOR_PAGE_UNLOADED'}),
    onUpdateField: (key, value) =>
        dispatch({ type: 'UPDATE_FIELD_COLLECTION', key, value })
});

class CollectionEditor extends Component {
    constructor() {
        super();

        const updateFieldEvent = key => ev => this.props.onUpdateField(key, ev.target.value);
        this.changeName = updateFieldEvent('name');
        this.changeDescription = updateFieldEvent('description');
        this.submitForm = ev => {
            ev.preventDefault();
            const collection = {
                name: this.props.name,
                description: this.props.description,
            };

            const id = { id: this.props.id };
            const promise = this.props.id ?
                agent.Collections.update(Object.assign(collection.name, collection.description, id)) :
                agent.Collections.create(collection.name, collection.description);

            this.props.onSubmit(promise);
        };
    }

    componentWillReceiveProps(nextProps) {
        if (this.props.match.params.id !== nextProps.match.params.id) {
            if (nextProps.match.params.id) {
                this.props.onUnload();
                return this.props.onLoad(agent.Collections.get(this.props.match.params.id));
            }
            this.props.onLoad(null);
        }
    }

    componentWillMount() {
        if (this.props.match.params.id) {
            return this.props.onLoad(agent.Collections.get(this.props.match.params.id));
        }
        this.props.onLoad(null);
    }

    componentWillUnmount() {
        this.props.onUnload();
    }


    render() {
        return (
            <div className="editor-page">
                <div className="container page">
                    <div className="row">
                        <div className="col-md-10 offset-md-1 col-xs-12">

                            <ListErrors errors={this.props.errors}></ListErrors>

                            <form>
                                <fieldset>

                                    <fieldset className="form-group">
                                        <input
                                            className="form-control form-control-lg"
                                            id="name"
                                            type="text"
                                            placeholder="Collection Name"
                                            value={this.props.name || ''}
                                            onChange={this.changeName} />
                                    </fieldset>

                                    <fieldset className="form-group">
                                        <textarea
                                            className="form-control"
                                            id="description"
                                            rows="*"
                                            placeholder="Describe your collection"
                                            value={this.props.description || ''}
                                            onChange={this.changeDescription}>
                                        </textarea>
                                    </fieldset>

                                    <button
                                        className="btn btn-lg pull-xs-right btn-primary"
                                        type="button"
                                        disabled={this.props.inProgress}
                                        onClick={this.submitForm}>
                                        {!this.props.match.params.id ? 'Create' : 'Update' } Collection
                                    </button>

                                </fieldset>
                            </form>
                        </div>
                    </div>
                </div>
            </div>
        );
    }
}


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

Common Reducer

We will need to handle redirection for users after they finish creating a collection, as they might assume if they stayed on the same page that there was some kind of error. We will create redirection for both submission and deletion actions that a user might make for a collection (in app/static/js/reducers/common.jsx).

#...
export default (state = defaultState, action) => {
  switch(action.type) {
      #...
        case 'COLLECTION_SUBMITTED':
            const redirectUrl = `collection/${action.payload.collection.id}`;
            return { ...state, redirectTo: redirectUrl };
        case 'DELETE_COLLECTION':
            return { ...state, redirectTo: '/' };
      #...

Routed

After, we will be creating the routing to the collection editor using our AppRouter component. As before, we import the component at the top of the file, and as we only want authenticated users to be able to create (let alone update) collections, we will wrap the editor in the AuthedRouter HOC (in app/static/js/AppRouter.jsx).

#...
import CollectionEditor from './components/collection/CollectionEditor';
#...


class AppRouter extends Component {
  #...
  
  render() {
    return (
      #...
        <Switch>
          <AuthedRoute path="/collection-editor/:id" component={CollectionEditor} />
          <AuthedRoute path="/collection-editor" component={CollectionEditor} />
        </Switch>
      #....
      

Heads Up

To complete our work on the editor, we need users to have a convenient access point so that we can add it to the navigation header component for authenticated users.

(in app/static/js/Header.jsx)
https://gist.github.com/Porter97/ae4b543bed01a521f9808b23ef140e4f

Good Form

With the front-end completed for now, we can head to the back-end to set up the forms and views for our collections. As we have already established the fields in the front end request, we need to allow the collection name and description to be sent and validated in the request. As a collection will need to have both of these fields, we can also set them with DataRequired validators (in app/main/forms.py)

#...
from wtforms.validators import Length, Regexp, DataRequired

#...
class CollectionForm(FlaskForm):
    class Meta:
        csrf = False

    name = StringField('name', validators=[DataRequired()])
    description = StringField('description', validators=[DataRequired()])

Usable Models

Like our user objects, we will need to provide the front-end with JSON representations of our collections. The front-end currently requires ID, name, and description values, but we will also include a createdAt and author JSON object for good measure (in app/models.py).

#...

class Collection(db.Model):
  #...
      def to_json(self):
        json_collection = {
            'id': self.id,
            'name': self.name,
            'description': self.description,
            'author': self.author.to_json(),
            'createdAt': self.timestamp,
        }
        return json_collection

A Collection Of Views

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

In our previous tutorial series, we covered the server-side code for synchronizing our database with Stream, including class methods for the creation, updating, and deleting of collection entries. For a recap of these methods, you can check out the tutorial here.

Our task now is converting the views to return JSON responses for the front-end instead of rendered templates (in app/main/views.py)

from flask import render_template, jsonify, flash, abort
from .forms import EditProfileForm, CollectionForm
from .errors import page_not_found, internal_server_error
from ..models import User, Collection, Permission'
#...

@main.route('/collections/<int:id>')
def get_collection(id):
    collection = Collection.query.get_or_404(id)
    return {'collection': collection.to_json()}


@main.route('/collections', methods=['POST'])
@login_required
def create_collection():
    form = CollectionForm()
    if current_user.can(Permission.WRITE) and form.validate_on_submit():
        collection = Collection(name=form.name.data,
                                description=form.description.data,
                                author=current_user._get_current_object())
        db.session.add(collection)
        current_user.follow_collection(collection)
        db.session.commit()
        if collection.add_to_stream():
            flash("You have created a new Collection!")
            return {'collection': collection.to_json()}
        else:
            db.session.delete(collection)
            db.session.commit()
            return internal_server_error('An Error occurred, please try again')
    return {'errors': {fieldName.title(): errorMessages for fieldName, errorMessages in form.errors.items()}}, 400


@main.route('/collections/<int:id>', methods=['PUT'])
@login_required
def update_collection(id):
    collection = Collection.query.get_or_404(id)
    if current_user != collection.author and \
            not current_user.can(Permission.ADMIN):
        abort(403)
    form = CollectionForm()
    if form.validate_on_submit():
        # Update Database
        collection.name = form.name.data
        collection.description = form.description.data
        db.session.commit()
        # Update Stream Activity
        if collection.update_stream():
            return {'collection': collection.to_json()}
    return {'errors': {fieldName.title(): errorMessages for fieldName, errorMessages in form.errors.items()}}, 400


@main.route('/collections/<int:id>', methods=['DELETE'])
@login_required
def delete_collection(id):
    collection = Collection.query.get_or_404(id)
    if current_user != collection.author and \
            not current_user.can(Permission.ADMIN):
        abort(403)
    if collection.delete_from_stream():
        db.session.delete(collection)
        db.session.commit()
        flash('Your Collection has been deleted')
        return {'success': 200}
    else:
        return internal_server_error('An Error occurred, please try again')

Sanity Check #1

Starting up our server and the watch build of our React app, we check to make sure that everything is working as it should. Navigating to the New Collection tab in the navbar, you should see a basic collection form complete with fields for the name and description, as well as the submission button.

Next Steps

Now that we can create and update a collection, our next step is to create a collection view screen. We already started this process in our common reducer with our redirect after a collection form submission action, although now it will redirect us to our 404 page. We also already have our agent set up for the requests, so we can skip right to the reducer.

Collection Reducer

We will start by creating a reducer for the collection component. This collection will need to address actions resulting from the loading of the page and assigning the collection to state as the payload of the request. The unloading action will erase the collection and return a blank slate state (in app/static/js/reducers/collection.jsx).

export default (state = {}, action) => {
    switch(action.type) {
        case 'COLLECTION_PAGE_LOADED':
            return {
                ...state,
                collection: action.payload.collection
            };
        case 'COLLECTION_PAGE_UNLOADED':
            return {};
        default:
            return state;
    }
};

Collection Page

After the reducer, we can now dig into creating the collection page. We will start with connecting the component to our reducer with mapStateToProps and mapDispatchToProps for the current user, as well as the onLoad and onUnload action dispatch. After that the componentWillMount method will send a request with our collection agent based on the props passed through the URL parameters. ComponentWillUnmount will remove the properties from our store. In our render, we will confirm there is a collection that was returned, if not, it will return the NotFound component.

Much like our profile page, the collection page will need to check against the current user to allow the owner to modify it. We can check the author properties passed with the collection against the current user to determine this ownership. We will also be adding in a CollectionMeta component underneath the name and description to provide metadata for the collection which we will define next (in app\static\js\components\collection\Collection.jsx).

import React from 'react';
import agent from '../../agent'
import { connect } from 'react-redux';

import CollectionMeta from './CollectionMeta'


const mapStateToProps = state => ({
    ...state.collection,
    currentUser: state.common.currentUser
});

const mapDispatchToProps = dispatch => ({
    onLoad: payload =>
        dispatch({ type: 'COLLECTION_PAGE_LOADED', payload }),
    onUnload: () =>
        dispatch({ type: 'COLLECTION_PAGE_UNLOADED' })
});

class Collection extends React.Component {
    componentWillMount() {
        this.props.onLoad(
            agent.Collections.get(this.props.match.params.id)
        )
    }

    componentWillUnmount() {
        this.props.onUnload();
    }

    render() {
        if (!this.props.collection) {
            return (
                <NotFound />
            );
        }

        const canModify = this.props.currentUser &&
            this.props.currentUser.username === this.props.collection.author.username;

        return (
            <div className="collection-page">
                <div className="banner">
                    <div className="container">

                        <h1>{this.props.collection.name}</h1>
                        <div>{this.props.collection.description}</div>
                        <CollectionMeta
                            collection={this.props.collection}
                            canModify={canModify} />

                    </div>
                </div>


                <hr />
            </div>
        );
    }
}

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

Meta, Dude

The collection metadata will include information about the author, as well as some data for the creation date. The author data will also link to the author to be able to check out their profile page. Beyond that, we will be passing the canModify property to the CollectionActions component that we will now have to create (in app\static\js\components\collection\CollectionMeta.jsx)

import React from 'react';
import { Link } from 'react-router-dom';
import CollectionActions from './CollectionActions';

const CollectionMeta = props => {
    const collection = props.collection;
    return (
        <div className="collection-meta">
            <Link to={`/@${collection.author.username}`}>
                <img src={collection.author.image} alt={collection.author.username} />
            </Link>

            <div className="info">
                <Link to={`/@${collection.author.username}`} className="author">
                    {collection.author.username}
                </Link>
                <span className="date">
                    {new Date(collection.createdAt).toDateString()}
                </span>
            </div>

            <CollectionActions canModify={props.canModify} collection={collection} />
        </div>
    )
}

export default CollectionMeta;

Collective Action

CollectionActions will give the author the ability to edit or delete the collection. As we will be using the delete action defined in the collection reducer, we will need to reference that in mapDispatchToProps. For the component itself, we will check again for the canModify property before rendering (in app\static\js\components\collection\CollectionActions.jsx)

import { Link } from 'react-router-dom';
import React from 'react';
import agent from '../../agent';
import { connect } from 'react-redux';

const mapDispatchToProps = dispatch => ({
    onClickDelete: payload =>
        dispatch({ type: 'DELETE_COLLECTION', payload })
});

const CollectionActions = props => {
    const collection = props.collection;
    const del = () => {
        props.onClickDelete(agent.Collections.del(collection.id))
    };
    if (props.canModify) {
        return (
            <span>
                <Link
                    to={`/collection-editor/${collection.id}`}
                    className="btn btn-outline-secondary btn-sm">
                    Edit Collection
                </Link>

                <button className="btn btn-outline-danger btn-sm" onClick={del}>
                    Delete Collection
                </button>
            </span>
        );
    }

    return (
        <span>
        </span>
    );
};

export default connect(() => ({}), mapDispatchToProps)(CollectionActions);

AppRouter.jsx

Now that the collection component is complete, we will now add it to our AppRouter. We will also provide it as an AuthedRoute so that our content will be able to be loaded from a users Stream credentials if they have access to it (in app/static/js/AppRouter.jsx)

#...
import Collection from './components/collection/Collection'

#...

class AppRouter extends Component {
  #...
  
  render() {
    return (
      #...
        <Switch>
          <AuthedRoute path='/collection/:id' component={Collection} />
        </Switch>
      #....
      

Sanity Check #2

As our final sanity check, we will check the collection page of a created collection to ensure that we can see the collectionActions component as the author to be able to edit and delete.

Sanity Check #3

Next, we need to check to make sure that the collection editor is working correctly with a previously defined collection, giving us the ‘Update Collection’ text in our submission button, as well as loading the name and description into the proper fields.

Finishing Up

Now, our collection CRUD functions are finished!

We have finally started to add to the real functionality of the site, with collections eventually serving as a jump-off point for integrating chat into our application. In our next article, we will be starting to add in similar components for adding content to the collections, as well as rendering the content as an infinite scroll. We will also be applying that same infinite scroll to the home page as our timeline feed!

As always, thanks for reading and Happy Hacking!

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->