jQuery ♥️ Redux
Adapting old school jQuery with Redux
Learned skills
- Swift / Objective-C -
- iOS Development, RxSwift
- JavaScript - ReactNative, React.js, Nodejs, Redux, RxJS
- Ruby - Ruby On Rails
Calvin Huang
Full Stack Engineer
FSM
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++);
});
not the worst
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
It's time to Redux
what is redux
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?
By the way, Redux is inspired by Flux.
redux flow
STATE
ACION
REDUCER
STORE
state tree
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
}
action
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],
}
action creator
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,
};
}
reconstruct our jq app
reducer
(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;
}
}
store
- Holds application state;
- Allows access to state via getState();
- Allows state to be updated via dispatch(action);
- Registers listeners via subscribe(listener);
// 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();
});
add redux-devtool middleware
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();
});
devtool we just added
Go Chrome Web Store to get useful plugin.
https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd
SO, here we go
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).
but!!!
it still can't be tested!
split app into modules
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!
split app into modules
<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
Then we can test it!
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);
});
...
...
wrap it up
jQuery ♥️ Redux
However...perhaps we can try React.js
GitHub bookshelf implemented by jQuery with redux
jq-with-redux
jQuery with Redux
By Calvin Huang
jQuery with Redux
jQuery ♥️ Redux = Good bye Flying Spaghetti Monster
- 1,934