In this tutorial, I’m going to show you how to create a simple Sticky Notes app using React, Redux, Selectors, Redux Sagas, a little bit of Node, and Cosmic JS. For the sake of understanding how to consume Restful API’s, this tutorial will show how to make AJAX (XHR) requests to the Cosmic JS API in order to retrieve, add, update, and delete data/media in our Cosmic JS Buckets. Let’s get started.
Download the GitHub repo Check out the demo
{ "name": "sticky-notes", "version": "1.0.0", "description": "Sticky Notes app built using react, redux & cosmic.", "main": "index.js", "scripts": { "start": "npm run build; node server/index.js", "start-dev": "nodemon server/index.js", "build": "webpack -p", "build-dev": "webpack -w", "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { "type": "git", "url": "git+https://github.com/jazibsawar/sticky-notes.git" }, "author": "Muhammad Musa", "license": "ISC", "bugs": { "url": "https://github.com/jazibsawar/sticky-notes/issues" }, "homepage": "https://github.com/jazibsawar/sticky-notes#readme", "dependencies": { "async": "^2.5.0", "babel": "^6.23.0", "babel-core": "^6.25.0", "babel-loader": "^7.1.1", "babel-plugin-transform-class-properties": "^6.24.1", "babel-plugin-transform-regenerator": "^6.24.1", "babel-polyfill": "^6.23.0", "babel-preset-es2015": "^6.24.1", "babel-preset-react": "^6.24.1", "babel-preset-stage-0": "^6.24.1", "body-parser": "^1.17.2", "cosmicjs": "^2.39.91", "express": "^4.15.3", "immutable": "^3.8.1", "moment": "^2.18.1", "prop-types": "^15.5.10", "react": "^15.6.1", "react-addons-css-transition-group": "^15.6.0", "react-addons-transition-group": "^15.6.0", "react-color": "^2.13.4", "react-dom": "^15.6.1", "react-modal-dialog": "^4.0.7", "react-redux": "^5.0.5", "react-router": "^3.0.5", "react-spinners": "0.0.30", "redux": "^3.7.2", "redux-logger": "^3.0.6", "redux-saga": "^0.15.6", "redux-thunk": "^2.2.0", "reselect": "^3.0.1", "styled-components": "^2.1.1", "volleyball": "^1.4.1", "webpack": "^3.4.1", "webpack-livereload-plugin": "^0.11.0", "whatwg-fetch": "^2.0.3" }, "devDependencies": { "chai": "^4.1.0", "cross-env": "^5.0.1", "mocha": "^3.5.0", "nodemon": "^1.11.0" }}
Now we we will set up our index.html in our client directory. Copy and paste the following code into your index.html file:
<!DOCTYPE html><html><head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Cosmic Sticky Notes App!</title> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"> <link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet" > <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/js/bootstrap.min.js"></script> <script src="/dist/bundle.js" defer></script></head><body id="top"><div id="root"></div></body></html>
Here, we are going to target our root div to place our react components in later. The bundle.js file located in our dist directory is what our webpack.config file will spit out after bundling all of our react components. Now, set up our webpack.config file to bundle all of our react files and export that bundle file to our dist directory. Copy the following code into your webpack.config.js file:
var path = require('path');var LiveReloadPlugin = require('webpack-livereload-plugin');var webpack = require('webpack');
module.exports = { entry: './client/index.js', output: { filename: 'bundle.js', path: path.resolve(__dirname, 'client/dist') }, context: __dirname, resolve: { extensions: ['.js', '.jsx', '.json', '*'] }, module: { rules: [{ test: /\.jsx?$/, exclude: /(node_modules|bower_components)/, loader: 'babel-loader', options: { presets: ['react', 'es2015', 'stage-0'] } }, ] }, plugins: [ new webpack.DefinePlugin({ 'process.env': { 'COSMIC_BUCKET': JSON.stringify(process.env.COSMIC_BUCKET), 'COSMIC_READ_KEY': JSON.stringify(process.env.COSMIC_READ_KEY), 'COSMIC_WRITE_KEY': JSON.stringify(process.env.COSMIC_WRITE_KEY) } }), new LiveReloadPlugin({appendScriptTag: true}), ]};
Then, we have server/index.js file when we used express to make our server.
In Client folder there is store.js file which is creating redux store to manage states and data. The things that are happening are as follows:
We used redux-thunk & redux-sagas middlewares.
We used combine Reducers to combine home & group reducer
Then we made store using the above things.
Then we trigger home & groups sagas.
import { createStore, applyMiddleware, combineReducers } from 'redux';import homeReducer from './containers/Home/reducer';import homeSagas from './containers/Home/sagas';
import groupReducer from './containers/Group/reducer';import groupSagas from './containers/Group/sagas';
import thunkMiddleware from 'redux-thunk';import createSagaMiddleware from 'redux-saga';
const sagaMiddleware = createSagaMiddleware();
const middlewares = [ sagaMiddleware, thunkMiddleware,];
const reducers = combineReducers({ home: homeReducer, group: groupReducer,});const store = createStore( reducers, applyMiddleware(...middlewares),);
sagaMiddleware.run(homeSagas)sagaMiddleware.run(groupSagas)
export default store;
Now we have client/Routes.js. Here, we have two routes:
Path=”/” => Lists all groups
Path=”/group/:groupSlug/:groupId” => list all notes against some groups
import React from 'react';import { Router, Route, browserHistory } from 'react-router';import { connect } from 'react-redux';import Home from './containers/Home';import Group from './containers/Group';
const Routes = ({}) => { return ( <Router history={browserHistory}> <Route path="/" component={Home} /> <Route path="/group/:groupSlug/:groupId" component={Group} /> </Router> )};
// const mapState = ({}) => ({});// const mapDispatch = {};
export default connect(null, null)(Routes);
In client folder we have create a configuration file inorder to save API_KEYS and COSMIC BUCKET NAME. You can edit this file to use your own keys and slugs.
export default { bucket: { slug: process.env.COSMIC_BUCKET || 'sticky-note-app', read_key: "YOUR READ KEY", write_key: "YOUR WRITE KEY", }}
Request uses whatwg-fetch to hit AJAX requests to the Cosmic JS API. This util is used to call those endpoints which are not present in the Cosmic JS package e.g deleteMedia and searchObjects based on filters
Cosmic JS uses cosmicjs package to hit AJAX request to Cosmic API.
This container lists down groups. This group add new Note Group. It also edits and delete NoteGroups. This selects NoteGroup data from reducers and passes them to their components. This also passes add, edit and delete NoteGroup actions to the home component.
This selects Notes data from reducers and passes them to their components. This also passes add, edit and delete Notes actions to the home component.
This component is for picking the color of Sticky Note.
import React, {Component} from 'react';import { ChromePicker } from 'react-color';
class ColorPickerField extends Component { constructor(props){ super(props); }
render() { return ( <ChromePicker color={this.props.color} onChangeComplete={this.props.changeColor} /> ) }}
export default ColorPickerField;
This component will show the popups and dialogs to get input data and output responses
import React, {Component} from 'react';import { ModalContainer, ModalDialog } from 'react-modal-dialog';
class Dialog extends Component { constructor(props){ super(props); }
handleClose = () => { this.props.closeDialog(); } render() { const { open } = this.props; return ( <div> { open && <ModalContainer onClose={this.handleClose}> <ModalDialog onClose={this.handleClose}> { this.props.children } </ModalDialog> </ModalContainer> } </div> ) }}
export default Dialog;
This is a loader component and it triggers whenever an ajax request in taking place.
import React, {Component} from 'react';import { BounceLoader } from 'react-spinners';
class Loader extends Component { constructor(props){ super(props); }
render() { const style = { position: "fixed", top: "50%", left: "50%", marginTop: "-9em", marginLeft: "-15em", }; return ( <div style={style}> <BounceLoader color={'#123abc'} loading size={300} /> </div> ) }}
export default Loader;
This component takes an array of options and convert them to stickynotes with edit, heading onclick and delete handler.
import React, {Component} from 'react';import { NoteListWrapper, NoteWrapper, Note } from './styles';import moment from 'moment/moment'class StickyNotes extends Component {
render() { const { options } = this.props; return ( <NoteListWrapper> { !!options && options.toArray().map((option, index) => { return <NoteWrapper key={`note_${index}`}> <Note backgroundColor={option.getIn(['metadata', 'color'])||"black"} color="#ffffff" rotate={index%2 === 0? -2 : 4}> <small>{moment(option.get('created')).fromNow()}</small> <h4 onClick={() => this.props.handleClick(option)}>{option.get('title').substring(0,11)}</h4> <p>{option.get('content').substring(0, 41)}</p> <span className="buttons"> <a onClick={() => this.props.deleteOption(option.toJS(), index)} className="pull-right"><i className="fa fa-trash-o"></i></a> <a onClick={() => this.props.editOption(option, index)} style={{ marginRight: "1vw" }} className="pull-right"><i className="fa fa-pencil"></i></a> </span> </Note> </NoteWrapper> }) } </NoteListWrapper> ) }}
export default StickyNotes;
This is a component where we feed NoteGroup options to Sticky Notes component and have add, edit and delete handlers.
import React, {Component} from 'react';import Dialog from '../Dialog';import StickyNotes from '../StickyNotes';import ColorPicker from '../ColorPicker';
class Home extends Component { constructor(props){ super(props); this.state = { title: "", content: "", color: "#fff", selectedGroup: null, openAddDialog: false, openEditDialog: false, group: { title: "", }, } }
addGroup = () => { const { title, content, color } = this.state; this.props.addGroup({ title, content, color, }); this.setState({ title: "", content: "", openAddDialog: false }) }
editGroup = () => { const { group, selectedGroup } = this.state; const { state } = this; this.props.editGroup({ title: group.title, content: group.content, color: group.color, }, group.slug, selectedGroup); this.setState({ ...state, group: { title: "" }, openEditDialog: false }) }
goToNoteGroup = (group) => { this.props.changeRoute(`/group/${group.get('slug')}/${group.get('_id')}`); }
editOption = (group, selectedGroup) => { this.setState({ openEditDialog: true, group: group.toJS(), selectedGroup, }); } render() { const { groups } = this.props; const { state } = this; const { title, content, color, openAddDialog, openEditDialog, group } = this.state;
const styles = { btnCircle: { width: "30px", height: "30px", textAlign: "center", padding: "6px 0", fontSize: "12px", fontWeight: "700", lineHeight: "1.42", borderRadius: "15px", margin: "10px", border: "none", outline: "none", float: "right" } }
return ( <div className="container-fluid"> <div className="row"> <button style={styles.btnCircle} className="btn btn-primary btn-lg" onClick={() => this.setState({ openAddDialog: true })}> <i className="fa fa-plus"></i> </button> <Dialog open={openAddDialog} closeDialog={() => this.setState({ openAddDialog: false })} > <div className="container-fluid"> <div className="col-xs-12"> <input placeholder="Enter Name ..." type="text" style={{ margin: "1vh 0" }} value={title} className="form-control" onChange={(e) => this.setState({ title: e.target.value })} /> </div> <div className="col-xs-12"> <textarea value={content} placeholder="Enter Description ..." rows="4" cols="50" style={{ margin: "1vh 0" }} className="form-control" onChange={(e) => this.setState({ content: e.target.value })} /> </div>
<div className="col-xs-12"> <ColorPicker color={color} changeColor={(color) => this.setState({ color: color.hex })} /> </div>
<div className="col-xs-12"> <input type="button" disabled={title === "" && "disabled"} style={{ margin: "1vh 0" }} value="Add Group" className="btn btn-primary btn-lg" onClick={this.addGroup} /> </div> </div> </Dialog>
<Dialog open={openEditDialog} closeDialog={() => this.setState({ openEditDialog: false })} > <div className="container-fluid"> <div className="col-xs-12"> <input style={{ margin: "1vh 0" }} type="text" value={group.title} className="form-control" onChange={(e) => this.setState({ ...state, group: { ...this.state.group, title: e.target.value } })} /> </div> <div className="col-xs-12"> <textarea value={group.content||""} style={{ margin: "1vh 0" }} type="text" className="form-control" onChange={(e) => this.setState({ ...state, group: { ...this.state.group, content: e.target.value } })} /> </div> <div className="col-xs-12"> <ColorPicker color={group.color} changeColor={(color) => this.setState({ ...state, group: { ...this.state.group, color: color.hex }})} /> </div> <div className="col-xs-12"> <input disabled={group.title === "" && "disabled"} style={{ margin: "1vh 0" }} type="button" value="Edit Group" className="btn btn-warning btn-lg" onClick={this.editGroup} /> </div>
</div> </Dialog>
{ <StickyNotes options={groups} editOption={this.editOption} deleteOption={this.props.deleteGroup} handleClick={this.goToNoteGroup} /> } </div> </div> ) }}
export default Home;
This is a component where we feed Notes options to StickyNotes component and have add, edit and delete handlers.
import React, {Component} from 'react';import Dialog from '../Dialog';import StickyNotes from '../StickyNotes';import ColorPicker from '../ColorPicker';
class Group extends Component { constructor(props){ super(props); this.state = { title: "", content: "", color: "", selectedNote: null, openAddDialog: false, openEditDialog: false, openViewDialog: false, note: { title: "", }, } }
addNote = () => { const { title, color, content } = this.state; const { groupId, addedMedia } = this.props; const image = this.refs.imageFile.files[0]; const file = this.refs.attachedFile.files[0]; this.props.addNote({ title, content, color, image, file, }, groupId); this.setState({ title: "", content: "", openAddDialog: false }); }
editNote = () => { const { note, selectedNote } = this.state; const { state } = this; let METAFIELDS = note; METAFIELDS.metafields[1].value = note.color || "#000" this.props.editNote({ title: note.title, color: note.color || "#000", content: note.content, metafields: METAFIELDS.metafields, }, note.slug, selectedNote); this.setState({ ...state, note: { title: "" }, openEditDialog: false }) }
editOption = (note, selectedNote) => { this.setState({ openEditDialog: true, note: note.toJS(), selectedNote, }); }
goToNote = (note) => { this.setState({ note: note.toJS(), openViewDialog: true, }) } render() { const { notes } = this.props; const { state } = this;
const { title, content, color, openAddDialog, openEditDialog, openViewDialog, note } = this.state;
const styles = { btnCircle: { width: "30px", height: "30px", textAlign: "center", padding: "6px 0", fontSize: "12px", fontWeight: "700", lineHeight: "1.42", borderRadius: "15px", margin: "10px", border: "none", outline: "none", float: "right" } }
return ( <div className="container-fluid"> <button style={styles.btnCircle} onClick={() => this.setState({ openAddDialog: true })} className="btn btn-primary btn-lg"> <i className="fa fa-plus"></i> </button> <Dialog open={openAddDialog} closeDialog={() => this.setState({ openAddDialog: false })} > <div className="container-fluid"> <div className="col-xs-12"> <input style={{ margin: "1vh 0" }} type="text" className="form-control" value={title} onChange={(e) => this.setState({ title: e.target.value })} /> <br /> </div> <div className="col-xs-12"> <textarea style={{ margin: "1vh 0" }} type="text" className="form-control" value={content} onChange={(e) => this.setState({ content: e.target.value })} /> <br /> </div> <div className="col-xs-12"> <input style={{ margin: "1vh 0" }} type="file" className="form-control" ref="imageFile" /> <br /> </div> <div className="col-xs-12"> <input style={{ margin: "1vh 0" }} type="file" className="form-control" ref="attachedFile" /> <br /> </div> <div className="col-xs-12"> <ColorPicker color={color} changeColor={(color) => this.setState({ color: color.hex })} /> </div> <div className="col-xs-12"> <input disabled={(title === "" || content === "") && "disabled"} style={{ margin: "1vh 0" }} type="button" className="btn btn-success btn-md" value="Add Note" onClick={this.addNote} /> </div> </div> </Dialog>
<Dialog open={openEditDialog} closeDialog={() => this.setState({ openEditDialog: false })} > <div className="container-fluid"> <div className="col-xs-12"> <input style={{ margin: "1vh 0" }} type="text" value={note.title} className="form-control" onChange={(e) => this.setState({ ...state, note: { ...this.state.note, title: e.target.value } })} /> </div> <div className="col-xs-12"> <textarea style={{ margin: "1vh 0" }} type="text" value={note.content||""} className="form-control" onChange={(e) => this.setState({ ...state, note: { ...this.state.note, content: e.target.value } })} /> </div> <div className="col-xs-12"> <ColorPicker color={note.color} changeColor={(color) => this.setState({ ...state, note: { ...this.state.note, color: color.hex }})} /> </div> <div className="col-xs-12"> <input disabled={(note.title === "" || note.content === "") && "disabled"} style={{ margin: "1vh 0" }} type="button" value="Edit Note" className="btn btn-warning btn-lg" onClick={this.editNote} /> </div> </div> </Dialog>
<Dialog open={openViewDialog} closeDialog={() => this.setState({ openViewDialog: false })} > <div className="container-fluid"> <div className="col-xs-12"> <h1 style={{ margin: "1vh 0" }}>{note.title}</h1> </div> <div className="col-xs-12"> <p style={{ margin: "1vh 0" }}>{note.content}</p> </div> { !!note.metadata && !!note.metadata.feature_image && <div className="col-xs-12"> <img style={{ margin: "1vh 0" }} width="128" height="128" src={note.metadata.feature_image.imgix_url} /> </div> } { !!note.metadata && !!note.metadata.attachment && <div className="col-xs-12"> <a style={{ margin: "1vh 0" }} href={note.metadata.attachment.imgix_url} target="_blank">Click on this link to open attachment in the new link</a> </div> } </div> </Dialog>
<StickyNotes options={notes} editOption={this.editOption} deleteOption={this.props.deleteNote} handleClick={this.goToNote} /> </div> ) }}
export default Group;
So, this is an app where every scenario of the Cosmic JS RESTful API is covered. I hope you liked the tutorial.
This article originally appears on the Cosmic JS Blog.