Adapting old school jQuery with Redux
Learned skills
Flying Spaghetti Monster
Spaghetti code is a pejorative phrase for source code that has a complex and tangled control structure, especially one using many GOTO statements, exceptions, threads, or other "unstructured" branching constructs. It is named such because program flow is conceptually like a bowl of spaghetti, i.e. twisted and tangled.
Template based GitHub bookshelf implemented by jQuery
Here is the code. Quite pretty, right?
var repos = [];
var bookmarks = [];
var page = 1;
const repoList = $
.templates(
'repo-list',
{ markup: '#repo-list', templates: { repo: $.templates('#repo') } },
);
function fetchPublicRepos(page = 1) {
$.getJSON(`https://api.github.com/search/repositories?q=language:javascript&per_page=10&page=${page}`)
.done((data) => {
const bookmarkIds = bookmarks.map(bookmark => bookmark.repo_id);
repos = repos.concat(data.items.map((repo) => Object.assign(repo, { is_saved: bookmarkIds.includes(repo.id) })));
$('#app').html(repoList.render({ repos: repos }));
});
}
function fetchBookmarks() {
$.getJSON('/api/v1/bookmarks')
.done((data) => {
bookmarks = data;
const bookmarkIds = bookmarks.map(bookmark => bookmark.repo_id);
repos.map((repo) => Object.assign(repo, { is_saved: bookmarkIds.includes(repo.id) }));
$('#app').html(repoList.render({ repos: repos }));
});
}
function intialApp() {
fetchPublicRepos(page++);
fetchBookmarks();
}
intialApp();
$(document).on('click', '.more-repo-btn', () => {
fetchPublicRepos(page++);
});
function toggleBookmark(repoId) {
const selectedRepo = repos.find(repo => repo.id === repoId);
selectedRepo.is_saved = !selectedRepo.is_saved;
$('#app').html(repoList.render({ repos: repos }));
if (selectedRepo.is_saved) {
const { full_name } = selectedRepo;
createBookmark(repoId, full_name);
} else {
deleteBookmark(repoId);
}
}
function createBookmark(repoId, fullName) {
$.ajax({
method: 'POST',
contentType: 'application/json',
url: '/api/v1/bookmarks',
data: JSON.stringify({ repo_id: repoId, full_name: fullName }),
error: (jqXHR, textStatus, error) => {
showNotification(`${error}: ${jqXHR.responseJSON.message}`);
const selectedRepo = repos.find(repo => repo.id === repoId);
selectedRepo.is_saved = !selectedRepo.is_saved;
$('#app').html(repoList.render({ repos: repos }));
},
});
}
function deleteBookmark(repoId) {
$.ajax({
method: 'DELETE',
contentType: 'json',
url: `/api/v1/bookmarks/${repoId}`,
error: (jqXHR, textStatus, error) => {
showNotification(error);
const selectedRepo = repos.find(repo => repo.id === repoId);
selectedRepo.is_saved = selectedRepo.is_saved;
$('#app').html(repoList.render({ repos: repos }));
},
});
}
It's not too bad, huh?
So......how can we test it?
Redux is a predictable state container for JavaScript apps. It helps you write applications that behave consistently, run in different environments (client, server, and native), and are easy to test. (From redux official site.)
And they have the VERY HIGH quality documentation!!!
We all comes from MVC/MVVM.
So...what's different between MVC/MVVM <= >Redux/Flux?
repos: []
repo: {}
repo: {}
repo: {}
{
"id": 10270250,
"name": "react",
"full_name": "facebook/react",
"owner": {
"login": "facebook",
"id": 69631,
"avatar_url": "https://avatars3.githubusercontent.com/u/69631?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/facebook",
"html_url": "https://github.com/facebook",
"followers_url": "https://api.github.com/users/facebook/followers",
"following_url": "https://api.github.com/users/facebook/following{/other_user}",
"gists_url": "https://api.github.com/users/facebook/gists{/gist_id}",
"starred_url": "https://api.github.com/users/facebook/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/facebook/subscriptions",
"organizations_url": "https://api.github.com/users/facebook/orgs",
"repos_url": "https://api.github.com/users/facebook/repos",
"events_url": "https://api.github.com/users/facebook/events{/privacy}",
"received_events_url": "https://api.github.com/users/facebook/received_events",
"type": "Organization",
"site_admin": false
},
"private": false,
"html_url": "https://github.com/facebook/react",
"description": "A declarative, efficient, and flexible JavaScript library for building user interfaces.",
"fork": false,
"url": "https://api.github.com/repos/facebook/react",
"forks_url": "https://api.github.com/repos/facebook/react/forks",
"keys_url": "https://api.github.com/repos/facebook/react/keys{/key_id}",
"collaborators_url": "https://api.github.com/repos/facebook/react/collaborators{/collaborator}",
"teams_url": "https://api.github.com/repos/facebook/react/teams",
"hooks_url": "https://api.github.com/repos/facebook/react/hooks",
"issue_events_url": "https://api.github.com/repos/facebook/react/issues/events{/number}",
"events_url": "https://api.github.com/repos/facebook/react/events",
"assignees_url": "https://api.github.com/repos/facebook/react/assignees{/user}",
"branches_url": "https://api.github.com/repos/facebook/react/branches{/branch}",
"tags_url": "https://api.github.com/repos/facebook/react/tags",
"blobs_url": "https://api.github.com/repos/facebook/react/git/blobs{/sha}",
"git_tags_url": "https://api.github.com/repos/facebook/react/git/tags{/sha}",
"git_refs_url": "https://api.github.com/repos/facebook/react/git/refs{/sha}",
"trees_url": "https://api.github.com/repos/facebook/react/git/trees{/sha}",
"statuses_url": "https://api.github.com/repos/facebook/react/statuses/{sha}",
"languages_url": "https://api.github.com/repos/facebook/react/languages",
"stargazers_url": "https://api.github.com/repos/facebook/react/stargazers",
"contributors_url": "https://api.github.com/repos/facebook/react/contributors",
"subscribers_url": "https://api.github.com/repos/facebook/react/subscribers",
"subscription_url": "https://api.github.com/repos/facebook/react/subscription",
"commits_url": "https://api.github.com/repos/facebook/react/commits{/sha}",
"git_commits_url": "https://api.github.com/repos/facebook/react/git/commits{/sha}",
"comments_url": "https://api.github.com/repos/facebook/react/comments{/number}",
"issue_comment_url": "https://api.github.com/repos/facebook/react/issues/comments{/number}",
"contents_url": "https://api.github.com/repos/facebook/react/contents/{+path}",
"compare_url": "https://api.github.com/repos/facebook/react/compare/{base}...{head}",
"merges_url": "https://api.github.com/repos/facebook/react/merges",
"archive_url": "https://api.github.com/repos/facebook/react/{archive_format}{/ref}",
"downloads_url": "https://api.github.com/repos/facebook/react/downloads",
"issues_url": "https://api.github.com/repos/facebook/react/issues{/number}",
"pulls_url": "https://api.github.com/repos/facebook/react/pulls{/number}",
"milestones_url": "https://api.github.com/repos/facebook/react/milestones{/number}",
"notifications_url": "https://api.github.com/repos/facebook/react/notifications{?since,all,participating}",
"labels_url": "https://api.github.com/repos/facebook/react/labels{/name}",
"releases_url": "https://api.github.com/repos/facebook/react/releases{/id}",
"deployments_url": "https://api.github.com/repos/facebook/react/deployments",
"created_at": "2013-05-24T16:15:54Z",
"updated_at": "2017-09-04T17:35:35Z",
"pushed_at": "2017-09-03T17:05:04Z",
"git_url": "git://github.com/facebook/react.git",
"ssh_url": "git@github.com:facebook/react.git",
"clone_url": "https://github.com/facebook/react.git",
"svn_url": "https://github.com/facebook/react",
"homepage": "https://facebook.github.io/react/",
"size": 130749,
"stargazers_count": 74879,
"watchers_count": 74879,
"language": "JavaScript",
"has_issues": true,
"has_projects": true,
"has_downloads": true,
"has_wiki": true,
"has_pages": true,
"forks_count": 14117,
"mirror_url": null,
"open_issues_count": 759,
"forks": 14117,
"open_issues": 759,
"watchers": 74879,
"default_branch": "master",
"score": 1
}
const [ACTION_NAME] = 'WHAT_IS_YOUR_ACTION_NAME';
The basic action structure.
{
type: [ACTION_NAME],
[YOUR_PAYLOAD_NAME]: [YOUR_PAYLOAD],
.....
[OTHER_PAYLOAD_NAME]: [YOUR_PAYLOAD],
}
Simply return a action.
function actionName(yourPayload, ....) {
return {
type: [ACTION_NAME],
yourPayload,
.....
}
}
/**
* Action types
*/
const FETCH_PUBLIC_REPOS = 'FETCH_PUBLIC_REPOS';
const RECEIVE_PUBLIC_REPOS = 'RECEIVE_FETCH_PUBLIC_REPOS';
/**
* Action creators
*/
function fetchPublicRepos() {
return {
type: FETCH_PUBLIC_REPOS,
page: page,
};
}
function receivePublicRepos(repos) {
return {
type: FETCH_PUBLIC_REPOS,
repos: repos,
};
}
(previousState, action) => newState
Handling actions
const intialState = [];
function repos(state = initialState, action) {
switch (action.type) {
case RECEIVE_PUBLIC_REPOS:
return state.concat(action.repos);
default: return initialState;
}
}
// When CommonJS module.exports, exports or ES6Module export not exists,
// Redux will register in global.Redux.
const store = Redux.createStore({
reducers,
});
store.subscribe(() => {
const state = store.getState();
});
To boost up your development speed.
// When CommonJS module.exports, exports or ES6Module export not exists,
// Redux will register in global.Redux.
const store = Redux.createStore({
reducers,
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(),
});
store.subscribe(() => {
const state = store.getState();
});
Go Chrome Web Store to get useful plugin.
https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd
Our jQuery app will reborn with Redux!!!!
/**
* Action creators
*/
function fetchPublicRepos() {
return {
type: FETCH_PUBLIC_REPOS,
};
}
function receivePublicRepos(repos) {
return {
type: RECEIVE_PUBLIC_REPOS,
repos: repos,
};
}
function fetchReposNextPage() {
return {
type: FETCH_REPOS_NEXT_PAGE,
}
}
function setReposAreSaved(bookmarkIds) {
return {
type: SET_REPOS_ARE_SAVED,
bookmarkIds: bookmarkIds,
}
}
function fetchBookmarks() {
return {
type: FETCH_BOOKMARKS,
}
}
function receiveBookmarks(bookmarks) {
return {
type: RECEIVE_BOOKMARKS,
bookmarks: bookmarks,
}
}
function toggleBookmark(repoId) {
return {
type: TOGGLE_BOOKMARK,
repoId: repoId,
};
}
function createBookmark(repoId, fullName) {
return {
type: CREATE_BOOKMARK,
repoId: repoId,
fullName: fullName,
};
}
function bookmarkCreated(repoId, fullName) {
return {
type: BOOKMARK_CREATED,
repoId: repoId,
fullName: fullName,
};
}
function deleteBookmark(repoId) {
return {
type: DELETE_BOOKMARK,
repoId: repoId,
};
}
function bookmarkDeleted(repoId) {
return {
type: BOOKMARK_DELETED,
repoId: repoId,
};
}
function showNotification(message) {
return {
type: SHOW_NOTIFICATION,
message: message,
}
}
/**
* Reducers
*/
function repos(state = { page: 1, data: [] }, action) {
switch (action.type) {
case FETCH_REPOS_NEXT_PAGE:
return Object.assign(state, { page: state.page + 1 });
case RECEIVE_PUBLIC_REPOS:
return Object.assign(state, { data: state.data.concat(action.repos) });
case SET_REPOS_ARE_SAVED:
return Object.assign(
state,
{ data: state.data.map(repo => (
Object.assign(repo, { is_saved: action.bookmarkIds.includes(repo.id) })
))}
);
default:
return state;
}
}
function bookmarks(state = [], action) {
switch (action.type) {
case RECEIVE_BOOKMARKS:
return state.concat(action.bookmarks);
case BOOKMARK_CREATED:
return state.concat({ repo_id: action.repoId, full_name: action.fullName });
case BOOKMARK_DELETED:
return state.filter(bookmark => bookmark.repo_id !== action.repoId);
default:
return state;
}
}
...
case CREATE_BOOKMARK: {
store.dispatch(bookmarkCreated(action.repoId, action.fullName));
$.ajax({
method: 'POST',
contentType: 'application/json',
url: '/api/v1/bookmarks',
data: JSON.stringify({ repo_id: action.repoId, full_name: action.fullName }),
error: (jqXHR, textStatus, error) => {
store.dispatch(showNotification(`${error}: ${jqXHR.responseJSON.message}`));
store.dispatch(bookmarkDeleted(action.repoId));
},
});
break;
}
case DELETE_BOOKMARK: {
store.dispatch(bookmarkDeleted(action.repoId));
$.ajax({
method: 'DELETE',
contentType: 'json',
url: `/api/v1/bookmarks/${action.repoId}`,
error: (jqXHR, textStatus, error) => {
const repo = state.repos.data.find(repo => repo.id === action.repoId);
store.dispatch(showNotification(error));
store.dispatch(bookmarkCreated(repo.id, repo.full_name));
},
});
break;
}
case BOOKMARK_CREATED:
case BOOKMARK_DELETED: {
store.dispatch(setReposAreSaved(state.bookmarks.map(bookmark => bookmark.repo_id)));
break;
}
case SET_REPOS_ARE_SAVED: {
$('#app').html(repoList.render({ repos: state.repos.data }));
break;
}
case SHOW_NOTIFICATION: {
const modal = $('#notification');
modal.find('.message').text(action.message);
modal.modal({ show: true });
break;
}
}
});
store.subscribe(() => {
const state = store.getState();
const action = state.lastAction;
switch (action.type) {
case FETCH_PUBLIC_REPOS: {
$.getJSON(`https://api.github.com/search/repositories?q=language:javascript&per_page=10&page=${action.page}`)
.done((data) => {
store.dispatch(receivePublicRepos(data.items));
});
break;
}
case FETCH_REPOS_NEXT_PAGE: {
store.dispatch(fetchPublicRepos());
break;
}
case FETCH_BOOKMARKS: {
$.getJSON('/api/v1/bookmarks')
.done((data) => {
store.dispatch(receiveBookmarks(data));
});
break;
}
case RECEIVE_PUBLIC_REPOS:
case RECEIVE_BOOKMARKS: {
store.dispatch(setReposAreSaved(state.bookmarks.map(bookmark => bookmark.repo_id)));
break;
}
case TOGGLE_BOOKMARK: {
if (state.bookmarks.find(bookmark => bookmark.repo_id === action.repoId)) {
store.dispatch(deleteBookmark(action.repoId));
} else {
const repo = state.repos.data.find(repo => repo.id === action.repoId);
store.dispatch(createBookmark(repo.id, repo.full_name));
}
break;
}
...
function intialApp() {
store.dispatch(fetchPublicRepos());
store.dispatch(fetchBookmarks());
}
intialApp();
$(document).on('click', '.more-repo-btn', () => {
store.dispatch(fetchReposNextPage());
});
$(document).on('click', '.bookmark', (e) => {
e.preventDefault();
const repoId = parseInt($(e.currentTarget).data('repo-id'), 10);
store.dispatch(toggleBookmark(repoId));
});
Didn't manipulate/hold state.
Put UI updating code together(DRY).
And wrap it up as ESModule for jest
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(factory((global.actions = global.actions || {})));
}(this, (function (exports) {
'use strict';
/**
* Action types
*/
const actionTypes = {
FETCH_PUBLIC_REPOS: 'FETCH_PUBLIC_REPOS',
RECEIVE_PUBLIC_REPOS: 'RECEIVE_PUBLIC_REPOS',
FETCH_REPOS_NEXT_PAGE: 'FETCH_REPOS_NEXT_PAGE',
....
}
/**
* Action creators
*/
const actions = {
fetchPublicRepos: () => ({
type: actionTypes.FETCH_PUBLIC_REPOS,
}),
....
showNotification: (message) => ({
type: actionTypes.SHOW_NOTIFICATION,
message: message,
}),
}
exports.types = actionTypes;
exports.default = Object.assign(actions, { types: actionTypes });
Object.defineProperty(exports, '__esModule', { value: true });
})));
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(factory((global.middleware = global.middleware || {})));
}(this, (function (exports) {
'use strict';
/**
* Middleware
*/
const middleware = (actionTypes, actions, $) => {
return store => next => action => {
const returnValue = next(action);
const state = store.getState();
switch (action.type) {
case actionTypes.FETCH_PUBLIC_REPOS: {
$.getJSON(`https://api.github.com/search/repositories?q=language:javascript&per_page=10&page=${action.page}`)
.done((data) => {
store.dispatch(actions.receivePublicRepos(data.items));
});
break;
}
case actionTypes.FETCH_REPOS_NEXT_PAGE: {
store.dispatch(actions.fetchPublicRepos());
break;
}
....
}
}
exports.default = middleware;
Object.defineProperty(exports, '__esModule', { value: true });
})));
Use middleware to handle asynchronous actions instead.
And DI is important!
<script src="/js/actions.js"></script>
<script src="/js/reducers.js"></script>
<script src="/js/middleware.js"></script>
<script>
const store = Redux.createStore(
reducers(actions.types, Redux),
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(),
Redux.applyMiddleware(
middleware.default(actions.types, actions.default, $),
),
);
....
</script>
And wrap it up as ESModule for jest
import configureMockStore from 'redux-mock-store';
import middleware from './middleware';
import actions, { types } from './actions';
describe('test middleware behavior', () => {
// Mock jQuery.
class jQuery {
constructor(selector, context) { }
html() { }
find() { return this; }
modal() { return this; }
text() {}
}
describe('when the network request is fine', () => {
const $ = (selector, context) => new jQuery(selector, context);
$.templates = () => ({ render: () => { } });
$.ajax = () => { };
const mockStore = configureMockStore([middleware(types, actions, $)]);
it('createBookmark action should triggers bookmarkCreated and setReposAreSaved action', () => {
const store = mockStore({
repos: { page: 1, data: [{ id: 1, full_name: 'foo/boo' }] },
bookmarks: [],
});
const expectedActions = [
actions.createBookmark(1, 'foo/boo'),
actions.bookmarkCreated(1, 'foo/boo'),
// There is no reducer included, so bookmarks array stil is empty.
actions.setReposAreSaved([]),
];
store.dispatch(expectedActions[0]);
expect(store.getActions()).toEqual(expectedActions);
});
...
...
However...perhaps we can try React.js
GitHub bookshelf implemented by jQuery with redux