66 | );
67 |
68 | }
69 |
70 | }
71 |
--------------------------------------------------------------------------------
/src/reducers/devices.js:
--------------------------------------------------------------------------------
1 | import {
2 | DEVICES_NAVIGATE,
3 | DEVICES_FETCHING,
4 | DEVICES_FETCHED,
5 | DEVICES_FETCHING_ONE,
6 | DEVICES_FETCHED_ONE,
7 | DEVICES_SET_SIMULATION
8 | } from '../actions/devices';
9 |
10 | const INITIAL_STATE = {
11 | scene: 'list', // active scene displayed by the 'devices' component
12 | items: [], // fetched list of devices
13 | itemsFetching: false, // to display a 'loading..' when fetching
14 | item: null, // stores the loaded item to be used on the form
15 | itemFetching: false, // to display a 'loading..' when opening the form
16 | simulated: false, // if is simulating remote calls with a delay
17 | };
18 |
19 | export default function(state = INITIAL_STATE, action) {
20 | switch (action.type) {
21 | // change the scene (form / list)
22 | case DEVICES_NAVIGATE:
23 | return { ...state, scene: action.payload };
24 |
25 | // the list is being loaded, show the loading.. and reset the items
26 | case DEVICES_FETCHING:
27 | return { ...state, itemsFetching: true, items: [] };
28 |
29 | // hide the loading and set the loaded data into items
30 | case DEVICES_FETCHED:
31 | return { ...state, itemsFetching: false, items: action.payload};
32 |
33 | // one item is being loaded, show a loading.. inside the form and reset the current item
34 | case DEVICES_FETCHING_ONE:
35 | return { ...state, itemFetching: true, item: null};
36 |
37 | // hide the loading.. inside the form and set the loaded data into our 'item'
38 | case DEVICES_FETCHED_ONE:
39 | return { ...state, itemFetching: false, item: action.payload};
40 |
41 | // status change on the simulation checkbox
42 | case DEVICES_SET_SIMULATION:
43 | return { ...state, simulated: action.payload};
44 |
45 | // do nothing
46 | default:
47 | return state;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 |
2 | React/Redux basic CRUD example
3 | -------------------------------
4 |
5 | This little app was made to test a basic CRUD for a webpapp. This was done thinking on a mobile or chrome application so this is not using routes.
6 |
7 | A separation between the state and the application data is in place to test a more realistic situation where you do async calls to a remote API, something missing on many redux/react examples.
8 |
9 | This is being developed while I'm learning about react/redux so the code shared here may contain newbie errors and is not following any particular standard, but it contains a bunch of comments that may help some one that is struggling to understand how react/redux works.
10 |
11 | [Demo](http://cristianszwarc.github.io/react_crud_localStorage/)
12 |
13 | **Local Storage**
14 | This uses local storage for two tasks:
15 |
16 | - **store the state** ([redux-localstorage](https://github.com/elgerlambert/redux-localstorage))
17 | this allows the webapp to be reloaded without losing the current state. (so it can be a hosted webapp that when added to IOS home screen does not lose the state each time the user goes away and comes back [stackoverflow](http://stackoverflow.com/questions/6930771/stop-reloading-of-web-app-launched-from-iphone-home-screen))
18 | - **store the CRUD data** ([store.js](https://github.com/marcuswestin/store.js/))
19 | when actions are dispatched, async calls are executed against a local api that takes care of the data, a delay time is in place so the "fetching" state can be shown.
20 |
21 | **Web app**
22 | Can be added to IOS/Andriod home screen and each time is loaded the state remains the same (because the persistent state plugin).
23 |
24 | **No routes**
25 | Routes are amazing and are a requirement in many cases but this is a trial to check an alternative way when there is no need to provide bookmarks to sections or server rendering.
26 |
27 | **To do**
28 | Learn more and improve this example.
29 |
30 | **Use**
31 | ```
32 | npm install
33 | npm start
34 | ```
35 |
36 | Open http://localhost:8080/
37 |
38 | **Screens**
39 |
40 | 
41 |
42 | 
43 |
44 | 
45 |
46 | **License**
47 | MIT
48 |
--------------------------------------------------------------------------------
/src/components/devices/Devices.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Component } from 'react';
3 |
4 | // components (scenes) that will be displayed by this component
5 | import List from './List';
6 | import Form from './Form';
7 |
8 | // scenes is a silly trick to avoid routes, check the file to see it how works
9 | import {Scene, SceneLink} from '../Scenes';
10 |
11 | export default class Devices extends Component {
12 |
13 | componentWillMount() {
14 | this.props.setSimulation(this.props.simulated);
15 | }
16 |
17 | render() {
18 | // extract some fields from the props (to avoid use this.prop.bla after)
19 | const { scene } = this.props;
20 |
21 | // return the layout for the "devices",
22 | // check that List and Form are being feed with the current props
23 | // because I don't want to create more containers for them,
24 | // maybe a better way should be pass in only the functions each one require
25 | return (
26 |
27 |
28 | Devices
29 |
30 |
31 |
32 | {/* yes.. this should be in a component instead of repeating it again here */}
33 |
56 | Use CTRL + H to show/hide the redux dev tool.
57 | Note that the state and the application data are independent,
58 | so despite you can time travel on the state you can not do it on changes pushed to the API
59 | just like when you are working with a real remote database.
60 | GitHub
61 |
62 |
63 |
64 | );
65 |
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/containers/DevicesContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux'
2 | import * as actions from '../actions/devices';
3 |
4 | import Devices from '../components/devices/Devices';
5 |
6 | // redux-form expects a promise to handle the submit
7 | // the save action could be used directly,
8 | // but we want to navigate away if saved correctly
9 | const handleSave = (values, dispatch) => {
10 | return new Promise((resolve, reject) => {
11 |
12 | // dispatch the save action
13 | dispatch(actions.save(values)).then(
14 | (data) => {
15 | // move away ad resolve the promise given to the form
16 | dispatch(actions.navigate('list'));
17 | resolve();
18 | }
19 | ).catch(
20 | (error) => {
21 | // unable to save, let the form know
22 | reject({_error: 'Error saving...'});
23 | }
24 | );
25 |
26 | });
27 | }
28 |
29 | // assign part of the state to the props (can return multiple items)
30 | const mapStateToProps = (state) => {
31 | return state.devices;
32 | }
33 |
34 | // map actions to this.props.someFunction
35 | const mapDispatchToProps = (dispatch) => {
36 | return {
37 | fetch: () => {
38 | dispatch(actions.fetch());
39 | },
40 |
41 | edit: (id) => {
42 | dispatch(actions.navigate('form')); // the form could be already filled
43 | dispatch(actions.fetchOne(id)); // but this fetch will clean it right away
44 | },
45 |
46 | remove: (id) => {
47 | dispatch(actions.remove(id));
48 | },
49 |
50 | save: handleSave, // the promise for redux-form
51 |
52 | add: () => {
53 | dispatch(actions.navigate('form')); // the form could be already filled
54 | dispatch(actions.fetchOne()); // but this fetch will clean it right away
55 | },
56 |
57 | navigate: (targetScene) => {
58 | dispatch(actions.navigate(targetScene));
59 | },
60 |
61 | switchSimulation: (currentStatus) => { // switchs the simulation on/off
62 | currentStatus = currentStatus ? false : true; // switchs the current status
63 | dispatch(actions.setSimulation(currentStatus));
64 | },
65 |
66 | setSimulation: (status) => { // when the app is loaded the simulation is set to off, we need to set the current status from the state when devices is shown
67 | dispatch(actions.setSimulation(status));
68 | },
69 |
70 | }
71 | }
72 |
73 | const DevicesContainer = connect(mapStateToProps, mapDispatchToProps)(Devices)
74 |
75 | export default DevicesContainer
76 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const merge = require('webpack-merge');
3 | const webpack = require('webpack');
4 |
5 | const TARGET = process.env.npm_lifecycle_event;
6 | const PATHS = {
7 | app: path.join(__dirname, 'src'),
8 | build: path.join(__dirname, 'build'),
9 | };
10 |
11 | process.env.BABEL_ENV = TARGET;
12 |
13 | const common = {
14 | context: PATHS.app,
15 | entry: [
16 | './index.js',
17 | './index.html',
18 | ],
19 | output: {
20 | path: PATHS.build,
21 | filename: 'bundle.js',
22 | },
23 | module: {
24 | loaders: [
25 | {
26 | test: /\.css$/,
27 | loaders: ['style', 'css'],
28 | include: PATHS.app,
29 | },
30 | {
31 | test: /\.html$/,
32 | loader: 'file?name=[name].[ext]',
33 | include: PATHS.app,
34 | },
35 | {
36 | test: /\.jsx?$/,
37 | loaders: ['babel?cacheDirectory'],
38 | include: PATHS.app,
39 | },
40 | ],
41 | },
42 | };
43 |
44 | if (TARGET === 'start') {
45 | module.exports = merge(common, {
46 | devtool: 'eval-source-map',
47 | devServer: {
48 | contentBase: PATHS.build,
49 |
50 | historyApiFallback: true,
51 | hot: true,
52 | inline: true,
53 | progress: true,
54 |
55 | // display only errors to reduce the amount of output
56 | stats: 'errors-only',
57 |
58 | // parse host and port from env so this is easy
59 | // to customize
60 | host: process.env.HOST,
61 | port: process.env.PORT,
62 | },
63 | plugins: [
64 | new webpack.HotModuleReplacementPlugin(),
65 | ],
66 | });
67 | } else {
68 | module.exports = merge(common, {
69 | plugins: [
70 | new webpack.optimize.OccurenceOrderPlugin(true),
71 |
72 | new webpack.DefinePlugin({
73 | 'process.env.NODE_ENV': '"production"'
74 | }),
75 |
76 | // Search for equal or similar files and deduplicate them in the output
77 | // https://webpack.github.io/docs/list-of-plugins.html#dedupeplugin
78 | new webpack.optimize.DedupePlugin(),
79 |
80 | // Minimize all JavaScript output of chunks
81 | // https://github.com/mishoo/UglifyJS2#compressor-options
82 | new webpack.optimize.UglifyJsPlugin({
83 | compress: {
84 | screw_ie8: true, // jscs:ignore requireCamelCaseOrUpperCaseIdentifiers
85 | warnings: false,
86 | },
87 | }),
88 |
89 | // A plugin for a more aggressive chunk merging strategy
90 | // https://webpack.github.io/docs/list-of-plugins.html#aggressivemergingplugin
91 | new webpack.optimize.AggressiveMergingPlugin(),
92 | ],
93 | });
94 | }
95 |
--------------------------------------------------------------------------------
/src/actions/devices.js:
--------------------------------------------------------------------------------
1 | export const DEVICES_NAVIGATE = 'DEVICES_NAVIGATE';
2 | export const DEVICES_FETCHING = 'DEVICES_FETCHING';
3 | export const DEVICES_FETCHED = 'DEVICES_FETCHED';
4 | export const DEVICES_FETCHING_ONE = 'DEVICES_FETCHING_ONE';
5 | export const DEVICES_FETCHED_ONE = 'DEVICES_FETCHED_ONE';
6 | export const DEVICES_DELETING = 'DEVICES_DELETING';
7 | export const DEVICES_SET_SIMULATION = 'DEVICES_SET_SIMULATION';
8 |
9 | import localApi from '../libs/localApi';
10 |
11 | // define a local db for devices (simulated async api)
12 | let myAPI = new localApi(
13 | {
14 | tableName: 'myDevices', // used as local storage key
15 | fields: { // row structure (pre loaded for new item)
16 | _id: null, // row key (required)
17 | title: 'New Device',
18 | port: '*',
19 | },
20 | delay: 0, // simulated delay
21 | }
22 | );
23 |
24 | export function navigate(value) {
25 | return {
26 | type: DEVICES_NAVIGATE,
27 | payload: value
28 | };
29 | }
30 |
31 | export function fetch() {
32 | return function (dispatch) {
33 |
34 | // show a loading
35 | dispatch(fetching())
36 |
37 | // async load
38 | myAPI.getAll().then(
39 | (data) => dispatch(fetched(data))
40 | );
41 | }
42 |
43 | }
44 |
45 | export function fetching() {
46 | return {
47 | type: DEVICES_FETCHING
48 | };
49 | }
50 |
51 | export function fetched(data) {
52 | return {
53 | type: DEVICES_FETCHED,
54 | payload: data
55 | };
56 | }
57 |
58 | export function fetchOne(id = null) {
59 | return function (dispatch) {
60 |
61 | // show a loading
62 | dispatch(fetchingOne())
63 |
64 | // async load
65 | myAPI.get(id).then(
66 | (data) => dispatch(fetchedOne(data))
67 | );
68 | }
69 | }
70 |
71 | export function fetchingOne() {
72 | return {
73 | type: DEVICES_FETCHING_ONE
74 | };
75 | }
76 |
77 | export function fetchedOne(data) {
78 | return {
79 | type: DEVICES_FETCHED_ONE,
80 | payload: data
81 | };
82 | }
83 |
84 | export function save(values, callback) {
85 | return function (dispatch) {
86 | // return the save promise
87 | return myAPI.save(values);
88 | }
89 |
90 | }
91 |
92 | export function remove(id = null) {
93 | return function (dispatch) {
94 |
95 | // async delete
96 | myAPI.remove(id).then(
97 | (data) => dispatch(fetched(data))
98 | );
99 | }
100 | }
101 |
102 | export function setSimulation(status) {
103 | if (status) {
104 | myAPI.delay = 700;
105 | } else {
106 | myAPI.delay = 0;
107 | }
108 |
109 | return {
110 | type: DEVICES_SET_SIMULATION,
111 | payload: status
112 | };
113 | }
114 |
--------------------------------------------------------------------------------
/src/libs/localApi.js:
--------------------------------------------------------------------------------
1 | const _ = require('lodash');
2 | const storeJs = require('./store.js');
3 |
4 | // this class allows to get/save objects just like if they were rows in a table
5 | // local storage is used and it gives back promises so async calls can be simulated
6 | // a delay can be provided so "fetching" states can be tested without using a server
7 | export default class localApi {
8 | constructor(options) {
9 | this.tableName = options.tableName ? options.tableName : 'localApi'; // local storage key
10 | this.delay = options.delay ? options.delay : 0; // increase delay to simulate remote calls
11 | this.empty = options.fields; // structure of each record
12 | this.empty._id = null; // enforce a null initial key
13 | }
14 |
15 | resolveWithDelay(resolve, data) {
16 | if (this.delay > 0) {
17 | window.setTimeout(function() {
18 | resolve(data);
19 | }, this.delay);
20 | } else {
21 | resolve(data);
22 | }
23 | }
24 |
25 | getDb() {
26 | let dbContent = storeJs.get(this.tableName);
27 | if (!dbContent) {
28 | // initialize
29 | this.commitDb();
30 | };
31 |
32 | return dbContent;
33 | }
34 |
35 | commitDb(dbContent = []) {
36 | storeJs.set(this.tableName, dbContent);
37 | }
38 |
39 | getAll() {
40 | return new Promise((resolve, reject) => {
41 | this.resolveWithDelay(resolve, this.getDb());
42 | });
43 | }
44 |
45 | get(id) {
46 | return new Promise((resolve, reject) => {
47 | if (!id) {
48 | // return a new empty one
49 | this.resolveWithDelay(resolve, this.empty);
50 | } else {
51 | let dbContent = this.getDb();
52 | this.resolveWithDelay(resolve, _.find(dbContent, ['_id', id]));
53 | }
54 | });
55 | }
56 |
57 | remove(id) {
58 | return new Promise((resolve, reject) => {
59 | let dbContent = this.getDb();
60 | if (id) {
61 | let index = _.findIndex(dbContent, ['_id', id]);
62 | if (index > -1) {
63 | dbContent.splice(index, 1);
64 | }
65 | }
66 |
67 | this.commitDb(dbContent);
68 |
69 | // return the new db
70 | this.resolveWithDelay(resolve, dbContent);
71 | });
72 | }
73 |
74 | save(values) {
75 | return new Promise((resolve, reject) => {
76 | let dbContent = this.getDb();
77 | let item = null;
78 |
79 | // adding
80 | if (!values._id) {
81 | let newId = 1;
82 | if (dbContent.length > 0) {
83 | let maxIdDevice = dbContent.reduce((prev, current) => (prev.y > current.y) ? prev : current);
84 | newId = 1 + maxIdDevice._id;
85 | }
86 |
87 | item = { ...values, _id: newId};
88 | dbContent.push(item);
89 |
90 | } else { // saving
91 | item = _.find(dbContent, ['_id', values._id]);
92 | if (item) {
93 | // update current values by new ones
94 | for (var prop in values) {
95 | if (values.hasOwnProperty(prop)) {
96 | item[prop] = values[prop];
97 | }
98 | }
99 | }
100 | }
101 |
102 | this.commitDb(dbContent);
103 | this.resolveWithDelay(resolve, item);
104 |
105 | });
106 |
107 | }
108 |
109 | }
110 |
--------------------------------------------------------------------------------
/src/libs/store.js:
--------------------------------------------------------------------------------
1 | // https://github.com/marcuswestin/store.js
2 | module.exports = (function() {
3 | // Store.js
4 | var store = {},
5 | win = (typeof window != 'undefined' ? window : global),
6 | doc = win.document,
7 | localStorageName = 'localStorage',
8 | scriptTag = 'script',
9 | storage
10 |
11 | store.disabled = false
12 | store.version = '1.3.20'
13 | store.set = function(key, value) {}
14 | store.get = function(key, defaultVal) {}
15 | store.has = function(key) { return store.get(key) !== undefined }
16 | store.remove = function(key) {}
17 | store.clear = function() {}
18 | store.transact = function(key, defaultVal, transactionFn) {
19 | if (transactionFn == null) {
20 | transactionFn = defaultVal
21 | defaultVal = null
22 | }
23 | if (defaultVal == null) {
24 | defaultVal = {}
25 | }
26 | var val = store.get(key, defaultVal)
27 | transactionFn(val)
28 | store.set(key, val)
29 | }
30 | store.getAll = function() {
31 | var ret = {}
32 | store.forEach(function(key, val) {
33 | ret[key] = val
34 | })
35 | return ret
36 | }
37 | store.forEach = function() {}
38 | store.serialize = function(value) {
39 | return JSON.stringify(value)
40 | }
41 | store.deserialize = function(value) {
42 | if (typeof value != 'string') { return undefined }
43 | try { return JSON.parse(value) }
44 | catch(e) { return value || undefined }
45 | }
46 |
47 | // Functions to encapsulate questionable FireFox 3.6.13 behavior
48 | // when about.config::dom.storage.enabled === false
49 | // See https://github.com/marcuswestin/store.js/issues#issue/13
50 | function isLocalStorageNameSupported() {
51 | try { return (localStorageName in win && win[localStorageName]) }
52 | catch(err) { return false }
53 | }
54 |
55 | if (isLocalStorageNameSupported()) {
56 | storage = win[localStorageName]
57 | store.set = function(key, val) {
58 | if (val === undefined) { return store.remove(key) }
59 | storage.setItem(key, store.serialize(val))
60 | return val
61 | }
62 | store.get = function(key, defaultVal) {
63 | var val = store.deserialize(storage.getItem(key))
64 | return (val === undefined ? defaultVal : val)
65 | }
66 | store.remove = function(key) { storage.removeItem(key) }
67 | store.clear = function() { storage.clear() }
68 | store.forEach = function(callback) {
69 | for (var i=0; idocument.w=window'+scriptTag+'>')
91 | storageContainer.close()
92 | storageOwner = storageContainer.w.frames[0].document
93 | storage = storageOwner.createElement('div')
94 | } catch(e) {
95 | // somehow ActiveXObject instantiation failed (perhaps some special
96 | // security settings or otherwse), fall back to per-path storage
97 | storage = doc.createElement('div')
98 | storageOwner = doc.body
99 | }
100 | var withIEStorage = function(storeFunction) {
101 | return function() {
102 | var args = Array.prototype.slice.call(arguments, 0)
103 | args.unshift(storage)
104 | // See http://msdn.microsoft.com/en-us/library/ms531081(v=VS.85).aspx
105 | // and http://msdn.microsoft.com/en-us/library/ms531424(v=VS.85).aspx
106 | storageOwner.appendChild(storage)
107 | storage.addBehavior('#default#userData')
108 | storage.load(localStorageName)
109 | var result = storeFunction.apply(store, args)
110 | storageOwner.removeChild(storage)
111 | return result
112 | }
113 | }
114 |
115 | // In IE7, keys cannot start with a digit or contain certain chars.
116 | // See https://github.com/marcuswestin/store.js/issues/40
117 | // See https://github.com/marcuswestin/store.js/issues/83
118 | var forbiddenCharsRegex = new RegExp("[!\"#$%&'()*+,/\\\\:;<=>?@[\\]^`{|}~]", "g")
119 | var ieKeyFix = function(key) {
120 | return key.replace(/^d/, '___$&').replace(forbiddenCharsRegex, '___')
121 | }
122 | store.set = withIEStorage(function(storage, key, val) {
123 | key = ieKeyFix(key)
124 | if (val === undefined) { return store.remove(key) }
125 | storage.setAttribute(key, store.serialize(val))
126 | storage.save(localStorageName)
127 | return val
128 | })
129 | store.get = withIEStorage(function(storage, key, defaultVal) {
130 | key = ieKeyFix(key)
131 | var val = store.deserialize(storage.getAttribute(key))
132 | return (val === undefined ? defaultVal : val)
133 | })
134 | store.remove = withIEStorage(function(storage, key) {
135 | key = ieKeyFix(key)
136 | storage.removeAttribute(key)
137 | storage.save(localStorageName)
138 | })
139 | store.clear = withIEStorage(function(storage) {
140 | var attributes = storage.XMLDocument.documentElement.attributes
141 | storage.load(localStorageName)
142 | for (var i=attributes.length-1; i>=0; i--) {
143 | storage.removeAttribute(attributes[i].name)
144 | }
145 | storage.save(localStorageName)
146 | })
147 | store.forEach = withIEStorage(function(storage, callback) {
148 | var attributes = storage.XMLDocument.documentElement.attributes
149 | for (var i=0, attr; attr=attributes[i]; ++i) {
150 | callback(attr.name, store.deserialize(storage.getAttribute(attr.name)))
151 | }
152 | })
153 | }
154 |
155 | try {
156 | var testKey = '__storejs__'
157 | store.set(testKey, testKey)
158 | if (store.get(testKey) != testKey) { store.disabled = true }
159 | store.remove(testKey)
160 | } catch(e) {
161 | store.disabled = true
162 | }
163 | store.enabled = !store.disabled
164 |
165 | return store
166 | }())
167 |
--------------------------------------------------------------------------------