├── .gitignore ├── README.md ├── bundle.js ├── css └── styles.css ├── index.html ├── package.json └── src ├── actions ├── auth.js ├── feedback.js ├── index.js └── quotes.js ├── constants.js ├── index.js ├── pages ├── components │ ├── authpanel.js │ ├── feedbackpanel.js │ └── quote.js ├── quoteslist.js └── wrapper.js ├── routes.js ├── store ├── index.js ├── initialstate.js └── reducers │ ├── auth.js │ ├── feedback.js │ ├── index.js │ └── quotes.js └── utils └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | node_modules 4 | dist 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | To play with this locally, download the files then run `npm install` followed by `npm run build`, and open up `index.html` in a browser! You must run a local webserver or allow outside origins. 2 | 3 | The app is published online [here](http://blog.krawaller.se/reduxfirebasedemo/), and there is a blog post walking through the code [here](http://blog.krawaller.se/posts/a-react-redux-firebase-app-with-authentication/). 4 | -------------------------------------------------------------------------------- /css/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 1em; 3 | } 4 | 5 | input { 6 | min-width: 300px; 7 | margin-right: 1em; 8 | } 9 | 10 | .wrapper { 11 | margin: 0 auto; 12 | max-width: 600px; 13 | } 14 | 15 | .authpanel, .feedback, .newquoteform, .quote { 16 | margin-bottom: 1em; 17 | padding: 3px; 18 | } 19 | 20 | .authpanel { 21 | border-bottom: 1px solid #CCC; 22 | } 23 | 24 | .feedback { 25 | border: 1px solid #555; 26 | padding: 0.5em; 27 | background-color: #BFE6C5; 28 | border-radius: 5px; 29 | } 30 | 31 | .feedback button { 32 | float: right; 33 | } 34 | 35 | .feedback.error { 36 | background-color: #E6C8C9; 37 | } 38 | 39 | .author { 40 | font-size: 0.85em; 41 | color: #555; 42 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "browserify": "=12.0.1", 4 | "firebase": "=2.3.1", 5 | "lodash": "^3.10.1", 6 | "react": "=0.14.0", 7 | "react-dom": "=0.14.0", 8 | "react-redux": "=4.0.0", 9 | "react-router": "=1.0.0-rc3", 10 | "reactify": "=1.1.1", 11 | "redux": "=3.0.4", 12 | "redux-thunk": "=1.0.0", 13 | "lodash": "=3.10.1" 14 | }, 15 | "scripts": { 16 | "build": "browserify --debug -t [reactify --es6] src/index.js > bundle.js" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/actions/auth.js: -------------------------------------------------------------------------------- 1 | /* 2 | This module contains action creators dealing with `appState.auth` 3 | */ 4 | 5 | var C = require("../constants"), 6 | Firebase = require("firebase"), 7 | fireRef = new Firebase(C.FIREBASE); 8 | 9 | module.exports = { 10 | // called at app start 11 | startListeningToAuth: function(){ 12 | return function(dispatch,getState){ 13 | fireRef.onAuth(function(authData){ 14 | if (authData){ 15 | dispatch({ 16 | type: C.LOGIN_USER, 17 | uid: authData.uid, 18 | username: authData.github.displayName || authData.github.username 19 | }); 20 | } else { 21 | if (getState().auth.currently !== C.ANONYMOUS){ // log out if not already logged out 22 | dispatch({type:C.LOGOUT}); 23 | } 24 | } 25 | }); 26 | } 27 | }, 28 | attemptLogin: function(){ 29 | return function(dispatch,getState){ 30 | dispatch({type:C.ATTEMPTING_LOGIN}); 31 | fireRef.authWithOAuthPopup("github", function(error, authData) { 32 | if (error) { 33 | dispatch({type:C.DISPLAY_ERROR,error:"Login failed! "+error}); 34 | dispatch({type:C.LOGOUT}); 35 | } else { 36 | // no need to do anything here, startListeningToAuth have already made sure that we update on changes 37 | } 38 | }); 39 | } 40 | }, 41 | logoutUser: function(){ 42 | return function(dispatch,getState){ 43 | dispatch({type:C.LOGOUT}); // don't really need to do this, but nice to get immediate feedback 44 | fireRef.unauth(); 45 | } 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/actions/feedback.js: -------------------------------------------------------------------------------- 1 | /* 2 | This module contains action creators dealing with `appState.feedback` 3 | The only thing that can happen here is that user dismisses feedback. 4 | */ 5 | 6 | var C = require("../constants"); 7 | 8 | module.exports = { 9 | dismissFeedback: function(num){ 10 | return {type:C.DISMISS_FEEDBACK,num:num}; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/actions/index.js: -------------------------------------------------------------------------------- 1 | var authActions = require("./auth"), 2 | quotesActions = require("./quotes"), 3 | feedbackActions = require("./feedback"); 4 | 5 | module.exports = Object.assign({},authActions,quotesActions,feedbackActions); -------------------------------------------------------------------------------- /src/actions/quotes.js: -------------------------------------------------------------------------------- 1 | /* 2 | This module contains action creators dealing with `appState.quotes` 3 | */ 4 | 5 | var C = require("../constants"), 6 | Firebase = require("firebase"), 7 | quotesRef = new Firebase(C.FIREBASE).child("quotes"), 8 | utils = require("../utils"); 9 | 10 | module.exports = { 11 | // called when the app starts. this means we immediately download all quotes, and 12 | // then receive all quotes again as soon as anyone changes anything. 13 | startListeningToQuotes: function(){ 14 | return function(dispatch,getState){ 15 | quotesRef.on("value",function(snapshot){ 16 | dispatch({ type: C.RECEIVE_QUOTES_DATA, data: snapshot.val() }); 17 | }); 18 | } 19 | }, 20 | startQuoteEdit: function(qid){ 21 | return {type:C.START_QUOTE_EDIT,qid}; 22 | }, 23 | cancelQuoteEdit: function(qid){ 24 | return {type:C.FINISH_QUOTE_EDIT,qid}; 25 | }, 26 | deleteQuote: function(qid){ 27 | return function(dispatch,getState){ 28 | dispatch({type:C.SUBMIT_QUOTE_EDIT,qid}); 29 | quotesRef.child(qid).remove(function(error){ 30 | dispatch({type:C.FINISH_QUOTE_EDIT,qid}); 31 | if (error){ 32 | dispatch({type:C.DISPLAY_ERROR,error:"Deletion failed! "+error}); 33 | } else { 34 | dispatch({type:C.DISPLAY_MESSAGE,message:"Quote successfully deleted!"}); 35 | } 36 | }); 37 | }; 38 | }, 39 | submitQuoteEdit: function(qid,content){ 40 | return function(dispatch,getState){ 41 | var state = getState(), 42 | username = state.auth.username, 43 | uid = state.auth.uid, 44 | error = utils.validateQuote(content); 45 | if (error){ 46 | dispatch({type:C.DISPLAY_ERROR,error}); 47 | } else { 48 | dispatch({type:C.SUBMIT_QUOTE_EDIT,qid}); 49 | quotesRef.child(qid).set({content,username,uid},function(error){ 50 | dispatch({type:C.FINISH_QUOTE_EDIT,qid}); 51 | if (error){ 52 | dispatch({type:C.DISPLAY_ERROR,error:"Update failed! "+error}); 53 | } else { 54 | dispatch({type:C.DISPLAY_MESSAGE,message:"Update successfully saved!"}); 55 | } 56 | }); 57 | } 58 | }; 59 | }, 60 | submitNewQuote: function(content){ 61 | return function(dispatch,getState){ 62 | var state = getState(), 63 | username = state.auth.username, 64 | uid = state.auth.uid, 65 | error = utils.validateQuote(content); 66 | if (error){ 67 | dispatch({type:C.DISPLAY_ERROR,error}); 68 | } else { 69 | dispatch({type:C.AWAIT_NEW_QUOTE_RESPONSE}); 70 | quotesRef.push({content,username,uid},function(error){ 71 | dispatch({type:C.RECEIVE_NEW_QUOTE_RESPONSE}); 72 | if (error){ 73 | dispatch({type:C.DISPLAY_ERROR,error:"Submission failed! "+error}); 74 | } else { 75 | dispatch({type:C.DISPLAY_MESSAGE,message:"Submission successfully saved!"}); 76 | } 77 | }); 78 | } 79 | } 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | /* 2 | This file contains all would-be-magic-strings in the app. 3 | */ 4 | 5 | module.exports = { 6 | // MISC 7 | FIREBASE: "https://reduxfirebase.firebaseio.com/", 8 | 9 | // UI FEEDBACK ACTIONS 10 | DISPLAY_ERROR: "DISPLAY_ERROR", 11 | DISPLAY_MESSAGE: "DISPLAY_MESSAGE", 12 | DISMISS_FEEDBACK: "DISMISS_FEEDBACK", 13 | 14 | // AUTH ACTIONS 15 | ATTEMPTING_LOGIN: "ATTEMPTING_LOGIN", 16 | LOGIN_USER: "LOGIN_USER", 17 | LOGOUT: "LOGOUT", 18 | 19 | // AUTH STATES 20 | LOGGED_IN: "LOGGED_IN", 21 | ANONYMOUS: "ANONYMOUS", 22 | AWAITING_AUTH_RESPONSE: "AWAITING_AUTH_RESPONSE", 23 | 24 | // QUOTE ACTIONS 25 | RECEIVE_QUOTES_DATA: "RECEIVE_QUOTES_DATA", 26 | AWAIT_NEW_QUOTE_RESPONSE: "AWAIT_NEW_QUOTE_RESPONSE", 27 | RECEIVE_NEW_QUOTE_RESPONSE: "RECEIVE_NEW_QUOTE_RESPONSE", 28 | START_QUOTE_EDIT: "START_QUOTE_EDIT", 29 | FINISH_QUOTE_EDIT: "FINISH_QUOTE_EDIT", 30 | SUBMIT_QUOTE_EDIT: "SUBMIT_QUOTE_EDIT", 31 | 32 | // QUOTE STATES 33 | EDITING_QUOTE: "EDITING_QUOTE", 34 | SUBMITTING_QUOTE: "SUBMITTING_QUOTE" 35 | }; -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | This is the entry point for the app! From here we merely import our routes definitions, 3 | then use React and React-DOM to render it. 4 | */ 5 | 6 | var React = require('react'), 7 | ReactDOM = require('react-dom'), 8 | Router = require('react-router').Router, 9 | Provider = require('react-redux').Provider, 10 | store = require('./store'), 11 | routes = require('./routes'), 12 | actions = require('./actions') 13 | 14 | ReactDOM.render( 15 | // The top-level Provider is what allows us to `connect` components to the store 16 | // using ReactRedux.connect 17 | 18 | 19 | , 20 | document.getElementById("root") 21 | ); 22 | 23 | // setup Firebase listeners 24 | setTimeout(function(){ 25 | store.dispatch( actions.startListeningToAuth() ); 26 | store.dispatch( actions.startListeningToQuotes() ); 27 | }); -------------------------------------------------------------------------------- /src/pages/components/authpanel.js: -------------------------------------------------------------------------------- 1 | var React = require("react"), 2 | ReactRedux = require("react-redux"), 3 | actions = require("../../actions"), 4 | C = require("../../constants"), 5 | Link = require("react-router").Link; 6 | 7 | var Authpanel = React.createClass({ 8 | render: function(){ 9 | var p = this.props, auth = p.auth; 10 | switch(auth.currently){ 11 | case C.LOGGED_IN: return ( 12 |
13 | Logged in as {auth.username}. 14 | {' '} 15 |
16 | ); 17 | case C.AWAITING_AUTH_RESPONSE: return ( 18 |
19 | 20 |
21 | ); 22 | default: return ( 23 |
24 | 25 |
26 | ); 27 | } 28 | } 29 | }); 30 | 31 | // now we connect the component to the Redux store: 32 | 33 | var mapStateToProps = function(appState){ 34 | // This component will have access to `appState.auth` through `this.props.auth` 35 | return {auth:appState.auth}; 36 | }; 37 | 38 | var mapDispatchToProps = function(dispatch){ 39 | return { 40 | attemptLogin: function(){ dispatch(actions.attemptLogin()); }, 41 | logoutUser: function(){ dispatch(actions.logoutUser()); } 42 | } 43 | }; 44 | 45 | module.exports = ReactRedux.connect(mapStateToProps,mapDispatchToProps)(Authpanel); 46 | -------------------------------------------------------------------------------- /src/pages/components/feedbackpanel.js: -------------------------------------------------------------------------------- 1 | var React = require("react"), 2 | ReactRedux = require("react-redux"), 3 | actions = require("../../actions"), 4 | C = require("../../constants"); 5 | 6 | var Feedbackpanel = React.createClass({ 7 | render: function(){ 8 | var p = this.props, rows = p.feedback.map(function(f,n){ 9 | return (
10 | {f.msg} 11 | 12 |
); 13 | }); 14 | return (
15 | {rows} 16 |
); 17 | } 18 | }); 19 | 20 | // now we connect the component to the Redux store: 21 | 22 | var mapStateToProps = function(appState){ 23 | // This component will have access to `appState.feedback` through `this.props.feedback` 24 | return {feedback:appState.feedback}; 25 | }; 26 | 27 | var mapDispatchToProps = function(dispatch){ 28 | return { 29 | dismissFeedback: function(n){ dispatch(actions.dismissFeedback(n)); } 30 | } 31 | }; 32 | 33 | module.exports = ReactRedux.connect(mapStateToProps,mapDispatchToProps)(Feedbackpanel); 34 | -------------------------------------------------------------------------------- /src/pages/components/quote.js: -------------------------------------------------------------------------------- 1 | var React = require("react"), 2 | C = require("../../constants"); 3 | 4 | var Quote = React.createClass({ 5 | submit: function(e){ 6 | var p = this.props, 7 | field = this.refs.field; 8 | p.submit(field.value); 9 | field.value = ""; 10 | e.preventDefault(); 11 | }, 12 | render: function(){ 13 | var p = this.props, 14 | q = p.quote, 15 | button; 16 | if (p.state === C.EDITING_QUOTE){ 17 | return (
18 | 19 | 20 | 21 |
); 22 | } 23 | if (!p.mayedit){ 24 | button = "" 25 | } else if (p.state === C.SUBMITTING_QUOTE) { 26 | button = ; 27 | } else { 28 | button = ; 29 | } 30 | return
{q.username+" said: "}{q.content} {button}
; 31 | } 32 | }); 33 | 34 | module.exports = Quote 35 | -------------------------------------------------------------------------------- /src/pages/quoteslist.js: -------------------------------------------------------------------------------- 1 | var React = require("react"), 2 | ptypes = React.PropTypes, 3 | ReactRedux = require("react-redux"), 4 | C = require("../constants"), 5 | _ = require("lodash"), 6 | actions = require("../actions/"), 7 | Quote = require("./components/quote"); 8 | 9 | var Quoteslist = React.createClass({ 10 | newQuote: function(e){ 11 | if (!this.props.quotes.submitting){ 12 | e.preventDefault(); 13 | this.props.submitNewQuote(this.refs.newquote.value); 14 | this.refs.newquote.value = ''; 15 | } 16 | }, 17 | render: function(){ 18 | var p = this.props, rows = _.map(p.quotes.data,function(quote,qid){ 19 | var quotestate = p.quotes.states[qid]; 20 | return ; 31 | }).reverse(); 32 | return (
33 | {p.auth.uid ?
34 | 35 | 36 |
:

Log in to add a new quote of your own!

} 37 | {p.quotes.hasreceiveddata ? rows : "Loading quotes..."} 38 |
); 39 | } 40 | }); 41 | 42 | // now we connect the component to the Redux store: 43 | 44 | var mapStateToProps = function(appState){ 45 | return { 46 | quotes: appState.quotes, 47 | auth: appState.auth 48 | }; 49 | }; 50 | 51 | var mapDispatchToProps = function(dispatch){ 52 | return { 53 | submitNewQuote: function(content){ dispatch(actions.submitNewQuote(content)); }, 54 | startEdit: function(qid){ dispatch(actions.startQuoteEdit(qid)); }, 55 | cancelEdit: function(qid){ dispatch(actions.cancelQuoteEdit(qid)); }, 56 | submitEdit: function(qid,content){ dispatch(actions.submitQuoteEdit(qid,content)); }, 57 | deleteQuote: function(qid){ dispatch(actions.deleteQuote(qid)); } 58 | } 59 | }; 60 | 61 | module.exports = ReactRedux.connect(mapStateToProps,mapDispatchToProps)(Quoteslist); 62 | -------------------------------------------------------------------------------- /src/pages/wrapper.js: -------------------------------------------------------------------------------- 1 | /* 2 | This is our top-level component. Sub-components matching specific routes will be 3 | contained in `this.props.children` and rendered out. 4 | */ 5 | 6 | var React = require('react'), 7 | Authpanel = require('./components/authpanel'), 8 | Feedbackpanel = require('./components/feedbackpanel'); 9 | 10 | var Wrapper = React.createClass({ 11 | render: function() { 12 | return ( 13 |
14 | 15 |
16 | 17 | {this.props.children} 18 |
19 |
20 | ); 21 | } 22 | }); 23 | 24 | module.exports = Wrapper; -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | /* 2 | This is the "sitemap" of our app! 3 | */ 4 | 5 | var React = require('react'), 6 | ReactRouter = require('react-router'), 7 | Route = ReactRouter.Route, 8 | IndexRoute = ReactRouter.IndexRoute, 9 | Wrapper = require('./pages/wrapper'), 10 | Quoteslist = require('./pages/quoteslist'); 11 | 12 | module.exports = ( 13 | 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | This file defines the main Redux Store. It is used by the app index.js file where it is given to 3 | the Provider element from ReactRedux, which allows smart components to `connect` to the store 4 | */ 5 | 6 | var Redux = require("redux"), 7 | rootReducer = require("./reducers"), 8 | initialState = require("./initialstate"), 9 | thunk = require('redux-thunk'); // allows us to use asynchronous actions 10 | 11 | 12 | // A super-simple logger 13 | var logger = store => next => action => { 14 | console.log('dispatching', action.type,action) 15 | var result = next(action) 16 | console.log('next state', store.getState()) 17 | return result 18 | } 19 | 20 | 21 | module.exports = Redux.applyMiddleware(thunk,logger)(Redux.createStore)(rootReducer,initialState); 22 | 23 | -------------------------------------------------------------------------------- /src/store/initialstate.js: -------------------------------------------------------------------------------- 1 | /* 2 | This is the initial state of the Redux Store. 3 | */ 4 | 5 | var C = require("../constants"); 6 | 7 | module.exports = { 8 | feedback: [ 9 | {msg:"Welcome to this little demo! It is meant to demonstrate three things:",error:false}, 10 | {msg:"1) How to use Redux + Firebase",error:false}, 11 | {msg:"2) How to use authentication in a Redux app",error:false}, 12 | {msg:"3) How to have all UI state in Redux and none in the components",error:false} 13 | ], 14 | auth: { 15 | currently: C.ANONYMOUS, 16 | username: null, 17 | uid: null 18 | }, 19 | quotes: { 20 | hasreceiveddata: false, 21 | submittingnew: false, 22 | states: {}, // this will store per quote id if we're reading, editing or awaiting DB response 23 | data: {} // this will contain firebase data 24 | } 25 | }; -------------------------------------------------------------------------------- /src/store/reducers/auth.js: -------------------------------------------------------------------------------- 1 | var C = require("../../constants"), 2 | initialState = require("../initialstate"); 3 | 4 | /* 5 | A reducer is a function that takes the current state and an action, and then returns a 6 | new state. This reducer is responsible for appState.auth data. 7 | See `initialstate.js` for a clear view of what it looks like! 8 | */ 9 | 10 | module.exports = function(currentstate,action){ 11 | switch(action.type){ 12 | case C.ATTEMPTING_LOGIN: 13 | return { 14 | currently: C.AWAITING_AUTH_RESPONSE, 15 | username: "guest", 16 | uid: null 17 | }; 18 | case C.LOGOUT: 19 | return { 20 | currently: C.ANONYMOUS, 21 | username: "guest", 22 | uid: null 23 | }; 24 | case C.LOGIN_USER: 25 | return { 26 | currently: C.LOGGED_IN, 27 | username: action.username, 28 | uid: action.uid 29 | }; 30 | default: return currentstate || initialState.auth; 31 | } 32 | }; -------------------------------------------------------------------------------- /src/store/reducers/feedback.js: -------------------------------------------------------------------------------- 1 | var C = require("../../constants"), 2 | initialState = require("../initialstate"); 3 | 4 | /* 5 | A reducer is a function that takes the current state and an action, and then returns a 6 | new state. This reducer is responsible for appState.feedback data, which is an array of messages. 7 | See `initialstate.js` for a clear view of what it looks like! 8 | */ 9 | 10 | module.exports = function(currentfeedback,action){ 11 | switch(action.type){ 12 | case C.DISMISS_FEEDBACK: 13 | return currentfeedback.filter((i,n)=>n!==action.num); 14 | case C.DISPLAY_ERROR: 15 | return currentfeedback.concat({msg:action.error,error:true}); 16 | case C.DISPLAY_MESSAGE: 17 | return currentfeedback.concat({msg:action.message,error:false}); 18 | default: return currentfeedback || initialState.feedback; 19 | } 20 | }; -------------------------------------------------------------------------------- /src/store/reducers/index.js: -------------------------------------------------------------------------------- 1 | var Redux = require("redux"), 2 | authReducer = require("./auth"), 3 | quotesReducer = require("./quotes"), 4 | feedbackReducer = require("./feedback"); 5 | 6 | var rootReducer = Redux.combineReducers({ 7 | auth: authReducer, 8 | quotes: quotesReducer, 9 | feedback: feedbackReducer 10 | }); 11 | 12 | module.exports = rootReducer; -------------------------------------------------------------------------------- /src/store/reducers/quotes.js: -------------------------------------------------------------------------------- 1 | var C = require("../../constants"), 2 | initialState = require("../initialstate"), 3 | _ = require("lodash"); 4 | 5 | /* 6 | A reducer is a function that takes the current state and an action, and then returns a 7 | new state. This reducer is responsible for appState.quotes data. 8 | See `initialstate.js` for a clear view of what it looks like! 9 | */ 10 | 11 | module.exports = function(currentstate,action){ 12 | var newstate; 13 | switch(action.type){ 14 | case C.RECEIVE_QUOTES_DATA: 15 | return Object.assign({},currentstate,{ 16 | hasreceiveddata: true, 17 | data: action.data 18 | }); 19 | case C.AWAIT_NEW_QUOTE_RESPONSE: 20 | return Object.assign({},currentstate,{ 21 | submittingnew: true 22 | }); 23 | case C.RECEIVE_NEW_QUOTE_RESPONSE: 24 | return Object.assign({},currentstate,{ 25 | submittingnew: false 26 | }); 27 | case C.START_QUOTE_EDIT: 28 | newstate = _.cloneDeep(currentstate); 29 | newstate.states[action.qid] = C.EDITING_QUOTE; 30 | return newstate; 31 | case C.FINISH_QUOTE_EDIT: 32 | newstate = _.cloneDeep(currentstate); 33 | delete newstate.states[action.qid]; 34 | return newstate; 35 | case C.SUBMIT_QUOTE_EDIT: 36 | newstate = _.cloneDeep(currentstate); 37 | newstate.states[action.qid] = C.SUBMITTING_QUOTE; 38 | return newstate; 39 | default: return currentstate || initialState.quotes; 40 | } 41 | }; -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | validateQuote: function(content){ 3 | if (!content || content.length < 10){ 4 | return "A quote needs at least 10 characters to be worthy of sharing with the world!"; 5 | } 6 | } 7 | }; --------------------------------------------------------------------------------