Browse Source

Major code review

Major code review, cleaning the code and adding a lot of comments. Also
implements a separate store to keep entities with a reference count and
garbage collection. This closes #15.

Known issues at the moment are:
* Webplayer is no longer working, it has to be refactored.
* AlbumPage is to be implemented.
Phyks (Lucas Verney) 5 years ago
parent
commit
fffe9c4cd3
100 changed files with 1873 additions and 513 deletions
  1. 1
    1
      .eslintignore
  2. 2
    1
      .eslintrc.js
  3. 5
    0
      CONTRIBUTING.md
  4. 0
    5
      TODO
  5. 178
    27
      app/actions/APIActions.js
  6. 102
    36
      app/actions/auth.js
  7. 61
    0
      app/actions/entities.js
  8. 25
    4
      app/actions/index.js
  9. 0
    7
      app/actions/paginate.js
  10. 24
    0
      app/actions/paginated.js
  11. 14
    0
      app/actions/pagination.js
  12. 6
    0
      app/actions/store.js
  13. 1
    0
      app/actions/webplayer.js
  14. 4
    0
      app/common/styles/common.scss
  15. 4
    1
      app/common/styles/hacks.scss
  16. 3
    0
      app/common/styles/index.js
  17. 5
    0
      app/common/utils/index.js
  18. 7
    0
      app/common/utils/jquery.js
  19. 10
    1
      app/common/utils/string.js
  20. 17
    19
      app/components/Album.jsx
  21. 10
    2
      app/components/Albums.jsx
  22. 30
    25
      app/components/Artist.jsx
  23. 11
    5
      app/components/Artists.jsx
  24. 1
    0
      app/components/Discover.jsx
  25. 46
    12
      app/components/Login.jsx
  26. 50
    12
      app/components/Songs.jsx
  27. 7
    1
      app/components/elements/DismissibleAlert.jsx
  28. 19
    3
      app/components/elements/FilterBar.jsx
  29. 90
    39
      app/components/elements/Grid.jsx
  30. 62
    41
      app/components/elements/Pagination.jsx
  31. 1
    0
      app/components/elements/WebPlayer.jsx
  32. 19
    14
      app/components/layouts/Sidebar.jsx
  33. 5
    0
      app/components/layouts/Simple.jsx
  34. 6
    3
      app/containers/App.jsx
  35. 18
    5
      app/containers/RequireAuthentication.js
  36. 3
    0
      app/containers/Root.jsx
  37. 0
    1
      app/locales/en-US/index.js
  38. 0
    1
      app/locales/fr-FR/index.js
  39. 1
    0
      app/locales/index.js
  40. 0
    5
      app/locales/messagesDescriptors/layouts/Sidebar.js
  41. 74
    17
      app/middleware/api.js
  42. 16
    10
      app/models/api.js
  43. 20
    11
      app/models/auth.js
  44. 22
    0
      app/models/entities.js
  45. 8
    2
      app/models/i18n.js
  46. 0
    10
      app/models/paginate.js
  47. 10
    0
      app/models/paginated.js
  48. 13
    12
      app/models/webplayer.js
  49. 23
    4
      app/reducers/auth.js
  50. 211
    0
      app/reducers/entities.js
  51. 15
    7
      app/reducers/index.js
  52. 29
    19
      app/reducers/paginated.js
  53. 1
    0
      app/reducers/webplayer.js
  54. 7
    4
      app/routes.js
  55. 1
    0
      app/store/configureStore.development.js
  56. 3
    0
      app/store/configureStore.js
  57. 1
    0
      app/store/configureStore.production.js
  58. 20
    2
      app/styles/Album.scss
  59. 21
    1
      app/styles/Artist.scss
  60. 6
    0
      app/styles/Discover.scss
  61. 5
    0
      app/styles/Login.scss
  62. 3
    0
      app/styles/Songs.scss
  63. 5
    0
      app/styles/elements/FilterBar.scss
  64. 5
    0
      app/styles/elements/Grid.scss
  65. 3
    0
      app/styles/elements/Pagination.scss
  66. 5
    0
      app/styles/elements/WebPlayer.scss
  67. 5
    0
      app/styles/layouts/Sidebar.scss
  68. 7
    3
      app/styles/variables.scss
  69. 32
    0
      app/utils/ampache.js
  70. 0
    3
      app/utils/common/array.js
  71. 0
    3
      app/utils/common/index.js
  72. 12
    0
      app/utils/immutable.js
  73. 4
    0
      app/utils/index.js
  74. 29
    2
      app/utils/locale.js
  75. 8
    0
      app/utils/misc.js
  76. 48
    0
      app/utils/pagination.js
  77. 13
    0
      app/utils/reducers.js
  78. 39
    0
      app/utils/url.js
  79. 0
    0
      app/vendor/ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.css
  80. 0
    0
      app/vendor/ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.js
  81. 31
    14
      app/views/AlbumsPage.jsx
  82. 39
    27
      app/views/ArtistPage.jsx
  83. 32
    14
      app/views/ArtistsPage.jsx
  84. 6
    0
      app/views/BrowsePage.jsx
  85. 5
    0
      app/views/DiscoverPage.jsx
  86. 5
    0
      app/views/HomePage.jsx
  87. 54
    27
      app/views/LoginPage.jsx
  88. 8
    1
      app/views/LogoutPage.jsx
  89. 40
    22
      app/views/SongsPage.jsx
  90. 2
    2
      app/views/WebPlayer.jsx
  91. 3
    0
      fix.ie9.js
  92. 6
    2
      hooks/pre-commit
  93. 35
    13
      index.all.js
  94. 14
    3
      index.development.js
  95. 3
    3
      index.html
  96. 5
    0
      index.js
  97. 9
    0
      index.production.js
  98. 3
    3
      public/1.1.js
  99. 1
    1
      public/1.1.js.map
  100. 0
    0
      public/favicon.ico

+ 1
- 1
.eslintignore View File

@@ -1,4 +1,4 @@
1 1
 public/*
2 2
 node_modules/*
3
-vendor/*
3
+app/vendor/*
4 4
 webpack.config.*

+ 2
- 1
.eslintrc.js View File

@@ -25,7 +25,8 @@ module.exports = {
25 25
     "rules": {
26 26
         "indent": [
27 27
             "error",
28
-            4
28
+            4,
29
+            { "SwitchCase": 1 }
29 30
         ],
30 31
         "linebreak-style": [
31 32
             "error",

+ 5
- 0
CONTRIBUTING.md View File

@@ -41,3 +41,8 @@ strings in the `./app/locales/$LOCALE/index.js` file you have just created.
41 41
 No strict coding style is used in this repo. ESLint and Stylelint, ran with
42 42
 `npm run test` ensures a certain coding style. Try to keep the coding style
43 43
 homogeneous.
44
+
45
+
46
+## Hooks
47
+
48
+Usefuls Git hooks are located in `hooks` folder.

+ 0
- 5
TODO View File

@@ -1,5 +0,0 @@
1
-5. Web player
2
-6. Homepage
3
-7. Settings
4
-8. Search
5
-9. Discover

+ 178
- 27
app/actions/APIActions.js View File

@@ -1,27 +1,52 @@
1
+/**
2
+ * This file implements actions to fetch and load data from the API.
3
+ */
4
+
5
+// NPM imports
1 6
 import { normalize, arrayOf } from "normalizr";
2 7
 import humps from "humps";
3 8
 
9
+// Other actions
4 10
 import { CALL_API } from "../middleware/api";
11
+import { pushEntities } from "./entities";
5 12
 
6
-import { artist, track, album } from "../models/api";
13
+// Models
14
+import { artist, song, album } from "../models/api";
7 15
 
16
+// Constants
8 17
 export const DEFAULT_LIMIT = 32;  /** Default max number of elements to retrieve. */
9 18
 
19
+
20
+/**
21
+ * This function wraps around an API action to generate actions trigger
22
+ * functions to load items etc.
23
+ *
24
+ * @param   action          API action.
25
+ * @param   requestType     Action type to trigger on request.
26
+ * @param   successType     Action type to trigger on success.
27
+ * @param   failureType     Action type to trigger on failure.
28
+ */
10 29
 export default function (action, requestType, successType, failureType) {
30
+    /** Get the name of the item associated with action */
11 31
     const itemName = action.rstrip("s");
12 32
 
13
-    const fetchItemsSuccess = function (jsonData, pageNumber) {
14
-        // Normalize data
15
-        jsonData = normalize(
33
+    /**
34
+     * Normalizr helper to normalize API response.
35
+     *
36
+     * @param   jsonData    The JS object returned by the API.
37
+     * @return  A normalized object.
38
+     */
39
+    const _normalizeAPIResponse = function (jsonData) {
40
+        return normalize(
16 41
             jsonData,
17 42
             {
18 43
                 artist: arrayOf(artist),
19 44
                 album: arrayOf(album),
20
-                song: arrayOf(track)
45
+                song: arrayOf(song)
21 46
             },
22 47
             {
48
+                // Use custom assignEntity function to delete useless fields
23 49
                 assignEntity: function (output, key, value) {
24
-                    // Delete useless fields
25 50
                     if (key == "sessionExpire") {
26 51
                         delete output.sessionExpire;
27 52
                     } else {
@@ -30,26 +55,67 @@ export default function (action, requestType, successType, failureType) {
30 55
                 }
31 56
             }
32 57
         );
58
+    };
33 59
 
34
-        const nPages = Math.ceil(jsonData.result[itemName].length / DEFAULT_LIMIT);
35
-        return {
36
-            type: successType,
37
-            payload: {
38
-                result: jsonData.result,
39
-                entities: jsonData.entities,
40
-                nPages: nPages,
41
-                currentPage: pageNumber
60
+    /**
61
+     * Callback on successful fetch of paginated items
62
+     *
63
+     * @param   jsonData    JS object returned from the API.
64
+     * @param   pageNumber  Number of the page that was fetched.
65
+     */
66
+    const fetchPaginatedItemsSuccess = function (jsonData, pageNumber, limit) {
67
+        jsonData = _normalizeAPIResponse(jsonData);
68
+
69
+        // Compute the total number of pages
70
+        const nPages = Math.ceil(jsonData.result[itemName].length / limit);
71
+
72
+        // Return success actions
73
+        return [
74
+            // Action for the global entities store
75
+            pushEntities(jsonData.entities, [itemName]),
76
+            // Action for the paginated store
77
+            {
78
+                type: successType,
79
+                payload: {
80
+                    type: itemName,
81
+                    result: jsonData.result[itemName],
82
+                    nPages: nPages,
83
+                    currentPage: pageNumber
84
+                }
42 85
             }
43
-        };
86
+        ];
44 87
     };
88
+
89
+    /**
90
+     * Callback on successful fetch of single item
91
+     *
92
+     * @param   jsonData    JS object returned from the API.
93
+     * @param   pageNumber  Number of the page that was fetched.
94
+     */
95
+    const fetchItemSuccess = function (jsonData) {
96
+        jsonData = _normalizeAPIResponse(jsonData);
97
+
98
+        return pushEntities(jsonData.entities, [itemName]);
99
+    };
100
+
101
+    /** Callback on request */
45 102
     const fetchItemsRequest = function () {
103
+        // Return a request type action
46 104
         return {
47 105
             type: requestType,
48 106
             payload: {
49 107
             }
50 108
         };
51 109
     };
110
+
111
+    /**
112
+     * Callback on failed fetch
113
+     *
114
+     * @param   error   An error object, either a string or an i18nError
115
+     *                  object.
116
+     */
52 117
     const fetchItemsFailure = function (error) {
118
+        // Return a failure type action
53 119
         return {
54 120
             type: failureType,
55 121
             payload: {
@@ -57,27 +123,48 @@ export default function (action, requestType, successType, failureType) {
57 123
             }
58 124
         };
59 125
     };
60
-    const fetchItems = function (endpoint, username, passphrase, filter, pageNumber, include = [], limit=DEFAULT_LIMIT) {
126
+
127
+    /**
128
+     * Method to trigger a fetch of items.
129
+     *
130
+     * @param   endpoint    Ampache server base URL.
131
+     * @param   username    Username to use for API request.
132
+     * @param   filter      An eventual filter to apply (mapped to API filter
133
+     *                      param)
134
+     * @param   pageNumber  Number of the page to fetch items from.
135
+     * @param   limit       Max number of items to fetch.
136
+     * @param   include     [Optional] A list of includes to return as well
137
+     *                      (mapped to API include param)
138
+     *
139
+     * @return  A CALL_API action to fetch the specified items.
140
+     */
141
+    const fetchItems = function (endpoint, username, passphrase, filter, pageNumber, limit, include = []) {
142
+        // Compute offset in number of items from the page number
61 143
         const offset = (pageNumber - 1) * DEFAULT_LIMIT;
144
+        // Set extra params for pagination
62 145
         let extraParams = {
63 146
             offset: offset,
64 147
             limit: limit
65 148
         };
149
+
150
+        // Handle filter
66 151
         if (filter) {
67 152
             extraParams.filter = filter;
68 153
         }
154
+
155
+        // Handle includes
69 156
         if (include && include.length > 0) {
70 157
             extraParams.include = include;
71 158
         }
159
+
160
+        // Return a CALL_API action
72 161
         return {
73 162
             type: CALL_API,
74 163
             payload: {
75 164
                 endpoint: endpoint,
76 165
                 dispatch: [
77 166
                     fetchItemsRequest,
78
-                    jsonData => dispatch => {
79
-                        dispatch(fetchItemsSuccess(jsonData, pageNumber));
80
-                    },
167
+                    null,
81 168
                     fetchItemsFailure
82 169
                 ],
83 170
                 action: action,
@@ -87,19 +174,83 @@ export default function (action, requestType, successType, failureType) {
87 174
             }
88 175
         };
89 176
     };
90
-    const loadItems = function({ pageNumber = 1, filter = null, include = [] } = {}) {
177
+
178
+    /**
179
+     * High level method to load paginated items from the API wihtout dealing about credentials.
180
+     *
181
+     * @param   pageNumber  [Optional] Number of the page to fetch items from.
182
+     * @param   filter      [Optional] An eventual filter to apply (mapped to
183
+     *                      API filter param)
184
+     * @param   include     [Optional] A list of includes to return as well
185
+     *                      (mapped to API include param)
186
+     *
187
+     * Dispatches the CALL_API action to fetch these items.
188
+     */
189
+    const loadPaginatedItems = function({ pageNumber = 1, limit = DEFAULT_LIMIT, filter = null, include = [] } = {}) {
91 190
         return (dispatch, getState) => {
191
+            // Get credentials from the state
92 192
             const { auth } = getState();
93
-            dispatch(fetchItems(auth.endpoint, auth.username, auth.token.token, filter, pageNumber, include));
193
+            // Get the fetch action to dispatch
194
+            const fetchAction = fetchItems(
195
+                auth.endpoint,
196
+                auth.username,
197
+                auth.token.token,
198
+                filter,
199
+                pageNumber,
200
+                limit,
201
+                include
202
+            );
203
+            // Set success callback
204
+            fetchAction.payload.dispatch[1] = (
205
+                jsonData => dispatch => {
206
+                    // Dispatch all the necessary actions
207
+                    const actions = fetchPaginatedItemsSuccess(jsonData, pageNumber, limit);
208
+                    actions.map(action => dispatch(action));
209
+                }
210
+            );
211
+            // Dispatch action
212
+            dispatch(fetchAction);
94 213
         };
95 214
     };
96 215
 
97
-    const camelizedAction = humps.pascalize(action);
216
+    /**
217
+     * High level method to load a single item from the API wihtout dealing about credentials.
218
+     *
219
+     * @param   filter      The filter to apply (mapped to API filter param)
220
+     * @param   include     [Optional] A list of includes to return as well
221
+     *                      (mapped to API include param)
222
+     *
223
+     * Dispatches the CALL_API action to fetch this item.
224
+     */
225
+    const loadItem = function({ filter = null, include = [] } = {}) {
226
+        return (dispatch, getState) => {
227
+            // Get credentials from the state
228
+            const { auth } = getState();
229
+            // Get the action to dispatch
230
+            const fetchAction = fetchItems(
231
+                auth.endpoint,
232
+                auth.username,
233
+                auth.token.token,
234
+                filter,
235
+                1,
236
+                DEFAULT_LIMIT,
237
+                include
238
+            );
239
+            // Set success callback
240
+            fetchAction.payload.dispatch[1] = (
241
+                jsonData => dispatch => {
242
+                    dispatch(fetchItemSuccess(jsonData));
243
+                }
244
+            );
245
+            // Dispatch action
246
+            dispatch(fetchAction);
247
+        };
248
+    };
249
+
250
+    // Remap the above methods to methods including item name
98 251
     var returned = {};
99
-    returned["fetch" + camelizedAction + "Success"] = fetchItemsSuccess;
100
-    returned["fetch" + camelizedAction + "Request"] = fetchItemsRequest;
101
-    returned["fetch" + camelizedAction + "Failure"] = fetchItemsFailure;
102
-    returned["fetch" + camelizedAction] = fetchItems;
103
-    returned["load" + camelizedAction] = loadItems;
252
+    const camelizedAction = humps.pascalize(action);
253
+    returned["loadPaginated" + camelizedAction] = loadPaginatedItems;
254
+    returned["load" + camelizedAction.rstrip("s")] = loadItem;
104 255
     return returned;
105 256
 }

+ 102
- 36
app/actions/auth.js View File

@@ -1,45 +1,36 @@
1
+/**
2
+ * This file implements authentication related actions.
3
+ */
4
+
5
+// NPM imports
1 6
 import { push } from "react-router-redux";
2
-import jsSHA from "jssha";
3 7
 import Cookies from "js-cookie";
4 8
 
5
-import { CALL_API } from "../middleware/api";
6
-import { invalidateStore } from "./store";
9
+// Local imports
10
+import { buildHMAC, cleanURL } from "../utils";
7 11
 
12
+// Models
8 13
 import { i18nRecord } from "../models/i18n";
9 14
 
10
-export const DEFAULT_SESSION_INTERVAL = 1800 * 1000;  // 30 mins default
11
-
12
-function _cleanEndpoint (endpoint) {
13
-    // Handle endpoints of the form "ampache.example.com"
14
-    if (
15
-        !endpoint.startsWith("//") &&
16
-            !endpoint.startsWith("http://") &&
17
-        !endpoint.startsWith("https://"))
18
-    {
19
-        endpoint = window.location.protocol + "//" + endpoint;
20
-    }
21
-    // Remove trailing slash and store endpoint
22
-    endpoint = endpoint.replace(/\/$/, "");
23
-    return endpoint;
24
-}
25
-
26
-function _buildHMAC (password) {
27
-    // Handle Ampache HMAC generation
28
-    const time = Math.floor(Date.now() / 1000);
15
+// Other actions and payload types
16
+import { CALL_API } from "../middleware/api";
17
+import { invalidateStore } from "./store";
29 18
 
30
-    let shaObj = new jsSHA("SHA-256", "TEXT");
31
-    shaObj.update(password);
32
-    const key = shaObj.getHash("HEX");
33 19
 
34
-    shaObj = new jsSHA("SHA-256", "TEXT");
35
-    shaObj.update(time + key);
20
+// Constants
21
+export const DEFAULT_SESSION_INTERVAL = 1800 * 1000;  // 30 mins long sessoins by default
36 22
 
37
-    return {
38
-        time: time,
39
-        passphrase: shaObj.getHash("HEX")
40
-    };
41
-}
42 23
 
24
+/**
25
+ * Dispatch a ping query to the API for login keepalive and prevent session
26
+ * from expiring.
27
+ *
28
+ * @param   username    Username to use
29
+ * @param   token       Token to revive
30
+ * @param   endpoint    Ampache base URL
31
+ *
32
+ * @return A CALL_API payload to keep session alive.
33
+ */
43 34
 export function loginKeepAlive(username, token, endpoint) {
44 35
     return {
45 36
         type: CALL_API,
@@ -60,7 +51,19 @@ export function loginKeepAlive(username, token, endpoint) {
60 51
     };
61 52
 }
62 53
 
54
+
63 55
 export const LOGIN_USER_SUCCESS = "LOGIN_USER_SUCCESS";
56
+/**
57
+ * Action to be called on successful login.
58
+ *
59
+ * @param   username    Username used for login
60
+ * @param   token       Token got back from the API
61
+ * @param   endpoint    Ampache server base URL
62
+ * @param   rememberMe  Whether to remember me or not
63
+ * @param   timerID     ID of the timer set for session keepalive.
64
+ *
65
+ * @return A login success payload.
66
+ */
64 67
 export function loginUserSuccess(username, token, endpoint, rememberMe, timerID) {
65 68
     return {
66 69
         type: LOGIN_USER_SUCCESS,
@@ -74,7 +77,16 @@ export function loginUserSuccess(username, token, endpoint, rememberMe, timerID)
74 77
     };
75 78
 }
76 79
 
80
+
77 81
 export const LOGIN_USER_FAILURE = "LOGIN_USER_FAILURE";
82
+/**
83
+ * Action to be called on failed login.
84
+ *
85
+ * This action removes any remember me cookie if any was set.
86
+ *
87
+ * @param   error   An error object, either string or i18nRecord.
88
+ * @return  A login failure payload.
89
+ */
78 90
 export function loginUserFailure(error) {
79 91
     Cookies.remove("username");
80 92
     Cookies.remove("token");
@@ -87,7 +99,14 @@ export function loginUserFailure(error) {
87 99
     };
88 100
 }
89 101
 
102
+
90 103
 export const LOGIN_USER_EXPIRED = "LOGIN_USER_EXPIRED";
104
+/**
105
+ * Action to be called when session is expired.
106
+ *
107
+ * @param   error   An error object, either a string or i18nRecord.
108
+ * @return  A session expired payload.
109
+ */
91 110
 export function loginUserExpired(error) {
92 111
     return {
93 112
         type: LOGIN_USER_EXPIRED,
@@ -97,14 +116,32 @@ export function loginUserExpired(error) {
97 116
     };
98 117
 }
99 118
 
119
+
100 120
 export const LOGIN_USER_REQUEST = "LOGIN_USER_REQUEST";
121
+/**
122
+ * Action to be called when login is requested.
123
+ *
124
+ * @return  A login request payload.
125
+ */
101 126
 export function loginUserRequest() {
102 127
     return {
103 128
         type: LOGIN_USER_REQUEST
104 129
     };
105 130
 }
106 131
 
132
+
107 133
 export const LOGOUT_USER = "LOGOUT_USER";
134
+/**
135
+ * Action to be called upon logout.
136
+ *
137
+ * This function clears the cookies set for remember me and the keep alive
138
+ * timer.
139
+ *
140
+ * @remark  This function does not clear the other stores, nor handle
141
+ *          redirection.
142
+ *
143
+ * @return  A logout payload.
144
+ */
108 145
 export function logout() {
109 146
     return (dispatch, state) => {
110 147
         const { auth } = state();
@@ -120,6 +157,14 @@ export function logout() {
120 157
     };
121 158
 }
122 159
 
160
+
161
+/**
162
+ * Action to be called to log a user out.
163
+ *
164
+ * This function clears the remember me cookies and the keepalive timer. It
165
+ * also clears the data behind authentication in the store and redirects to
166
+ * login page.
167
+ */
123 168
 export function logoutAndRedirect() {
124 169
     return (dispatch) => {
125 170
         dispatch(logout());
@@ -128,14 +173,30 @@ export function logoutAndRedirect() {
128 173
     };
129 174
 }
130 175
 
176
+
177
+/**
178
+ * Action to be called to log a user in.
179
+ *
180
+ * @param   username    Username to use.
181
+ * @param   passwordOrToken     User password, or previous token to revive.
182
+ * @param   endpoint    Ampache server base URL.
183
+ * @param   rememberMe  Whether to rememberMe or not
184
+ * @param[optional]     redirect    Page to redirect to after login.
185
+ * @param[optional]     isToken     Whether passwordOrToken is a password or a
186
+ *                                  token.
187
+ *
188
+ * @return  A CALL_API payload to perform login.
189
+ */
131 190
 export function loginUser(username, passwordOrToken, endpoint, rememberMe, redirect="/", isToken=false) {
132
-    endpoint = _cleanEndpoint(endpoint);
191
+    // Clean endpoint
192
+    endpoint = cleanURL(endpoint);
193
+
194
+    // Get passphrase and time parameters
133 195
     let time = 0;
134 196
     let passphrase = passwordOrToken;
135
-
136 197
     if (!isToken) {
137 198
         // Standard password connection
138
-        const HMAC = _buildHMAC(passwordOrToken);
199
+        const HMAC = buildHMAC(passwordOrToken);
139 200
         time = HMAC.time;
140 201
         passphrase = HMAC.passphrase;
141 202
     } else {
@@ -147,6 +208,7 @@ export function loginUser(username, passwordOrToken, endpoint, rememberMe, redir
147 208
         time = Math.floor(Date.now() / 1000);
148 209
         passphrase = passwordOrToken.token;
149 210
     }
211
+
150 212
     return {
151 213
         type: CALL_API,
152 214
         payload: {
@@ -155,23 +217,27 @@ export function loginUser(username, passwordOrToken, endpoint, rememberMe, redir
155 217
                 loginUserRequest,
156 218
                 jsonData => dispatch => {
157 219
                     if (!jsonData.auth || !jsonData.sessionExpire) {
220
+                        // On success, check that we are actually authenticated
158 221
                         return dispatch(loginUserFailure(new i18nRecord({ id: "app.api.error", values: {} })));
159 222
                     }
223
+                    // Get token from the API
160 224
                     const token = {
161 225
                         token: jsonData.auth,
162 226
                         expires: new Date(jsonData.sessionExpire)
163 227
                     };
164
-                    // Dispatch success
228
+                    // Handle session keep alive timer
165 229
                     const timerID = setInterval(
166 230
                         () => dispatch(loginKeepAlive(username, token.token, endpoint)),
167 231
                         DEFAULT_SESSION_INTERVAL
168 232
                     );
169 233
                     if (rememberMe) {
234
+                        // Handle remember me option
170 235
                         const cookiesOption = { expires: token.expires };
171 236
                         Cookies.set("username", username, cookiesOption);
172 237
                         Cookies.set("token", token, cookiesOption);
173 238
                         Cookies.set("endpoint", endpoint, cookiesOption);
174 239
                     }
240
+                    // Dispatch login success
175 241
                     dispatch(loginUserSuccess(username, token, endpoint, rememberMe, timerID));
176 242
                     // Redirect
177 243
                     dispatch(push(redirect));

+ 61
- 0
app/actions/entities.js View File

@@ -0,0 +1,61 @@
1
+/**
2
+ * This file implements actions related to global entities store.
3
+ */
4
+
5
+export const PUSH_ENTITIES = "PUSH_ENTITIES";
6
+/**
7
+ * Push some entities in the global entities store.
8
+ *
9
+ * @param   entities    An entities mapping, such as the one in the entities
10
+ *                      store: type => id => entity.
11
+ * @param   refCountType    An array of entities type to consider for
12
+ *                          increasing reference counting (elements loaded as nested objects)
13
+ * @return  A PUSH_ENTITIES action.
14
+ */
15
+export function pushEntities(entities, refCountType=["album", "artist", "song"]) {
16
+    return {
17
+        type: PUSH_ENTITIES,
18
+        payload: {
19
+            entities: entities,
20
+            refCountType: refCountType
21
+        }
22
+    };
23
+}
24
+
25
+
26
+export const INCREMENT_REFCOUNT = "INCREMENT_REFCOUNT";
27
+/**
28
+ * Increment the reference counter for given entities.
29
+ *
30
+ * @param   ids     A mapping type => list of IDs, each ID being the one of an
31
+ *                  entity to increment reference counter. List of IDs must be
32
+ *                  a JS Object.
33
+ * @return  An INCREMENT_REFCOUNT action.
34
+ */
35
+export function incrementRefCount(entities) {
36
+    return {
37
+        type: INCREMENT_REFCOUNT,
38
+        payload: {
39
+            entities: entities
40
+        }
41
+    };
42
+}
43
+
44
+
45
+export const DECREMENT_REFCOUNT = "DECREMENT_REFCOUNT";
46
+/**
47
+ * Decrement the reference counter for given entities.
48
+ *
49
+ * @param   ids     A mapping type => list of IDs, each ID being the one of an
50
+ *                  entity to decrement reference counter. List of IDs must be
51
+ *                  a JS Object.
52
+ * @return  A DECREMENT_REFCOUNT action.
53
+ */
54
+export function decrementRefCount(entities) {
55
+    return {
56
+        type: DECREMENT_REFCOUNT,
57
+        payload: {
58
+            entities: entities
59
+        }
60
+    };
61
+}

+ 25
- 4
app/actions/index.js View File

@@ -1,14 +1,35 @@
1
+/**
2
+ * Export all the available actions
3
+ */
4
+
5
+// Auth related actions
1 6
 export * from "./auth";
2 7
 
8
+// API related actions for all the available types
3 9
 import APIAction from "./APIActions";
4 10
 
11
+// Actions related to API
5 12
 export const API_SUCCESS = "API_SUCCESS";
6 13
 export const API_REQUEST = "API_REQUEST";
7 14
 export const API_FAILURE = "API_FAILURE";
8
-export var { loadArtists } = APIAction("artists", API_REQUEST, API_SUCCESS, API_FAILURE);
9
-export var { loadAlbums } = APIAction("albums", API_REQUEST, API_SUCCESS, API_FAILURE);
10
-export var { loadSongs } = APIAction("songs", API_REQUEST, API_SUCCESS, API_FAILURE);
15
+export var {
16
+    loadPaginatedArtists, loadArtist } = APIAction("artists", API_REQUEST, API_SUCCESS, API_FAILURE);
17
+export var {
18
+    loadPaginatedAlbums, loadAlbum } = APIAction("albums", API_REQUEST, API_SUCCESS, API_FAILURE);
19
+export var {
20
+    loadPaginatedSongs, loadSong } = APIAction("songs", API_REQUEST, API_SUCCESS, API_FAILURE);
21
+
22
+// Entities actions
23
+export * from "./entities";
24
+
25
+// Paginated views store actions
26
+export * from "./paginated";
11 27
 
12
-export * from "./paginate";
28
+// Pagination actions
29
+export * from "./pagination";
30
+
31
+// Store actions
13 32
 export * from "./store";
33
+
34
+// Webplayer actions
14 35
 export * from "./webplayer";

+ 0
- 7
app/actions/paginate.js View File

@@ -1,7 +0,0 @@
1
-import { push } from "react-router-redux";
2
-
3
-export function goToPage(pageLocation) {
4
-    return (dispatch) => {
5
-        dispatch(push(pageLocation));
6
-    };
7
-}

+ 24
- 0
app/actions/paginated.js View File

@@ -0,0 +1,24 @@
1
+/**
2
+ * These actions are actions acting directly on the paginated views store.
3
+ */
4
+
5
+// Other actions
6
+import { decrementRefCount } from "./entities";
7
+
8
+
9
+/** Define an action to invalidate results in paginated store. */
10
+export const CLEAR_RESULTS = "CLEAR_RESULTS";
11
+export function clearResults() {
12
+    return (dispatch, getState) => {
13
+        // Decrement reference counter
14
+        const paginatedStore = getState().paginated;
15
+        const entities = {};
16
+        entities[paginatedStore.get("type")] = paginatedStore.get("result").toJS();
17
+        dispatch(decrementRefCount(entities));
18
+
19
+        // Clear results in store
20
+        dispatch({
21
+            type: CLEAR_RESULTS
22
+        });
23
+    };
24
+}

+ 14
- 0
app/actions/pagination.js View File

@@ -0,0 +1,14 @@
1
+/**
2
+ * This file defines pagination related actions.
3
+ */
4
+
5
+// NPM imports
6
+import { push } from "react-router-redux";
7
+
8
+/** Define an action to go to a specific page. */
9
+export function goToPage(pageLocation) {
10
+    return (dispatch) => {
11
+        // Just push the new page location in react-router.
12
+        dispatch(push(pageLocation));
13
+    };
14
+}

+ 6
- 0
app/actions/store.js View File

@@ -1,3 +1,9 @@
1
+/**
2
+ * These actions are actions acting directly on all the available stores.
3
+ */
4
+
5
+
6
+/** Define an action to invalidate all the stores, e.g. in case of logout. */
1 7
 export const INVALIDATE_STORE = "INVALIDATE_STORE";
2 8
 export function invalidateStore() {
3 9
     return {

+ 1
- 0
app/actions/webplayer.js View File

@@ -1,3 +1,4 @@
1
+// TODO: This file is not finished
1 2
 export const PLAY_PAUSE = "PLAY_PAUSE";
2 3
 /**
3 4
  * true to play, false to pause.

app/styles/common/common.scss → app/common/styles/common.scss View File

@@ -1,4 +1,8 @@
1
+/**
2
+ * Common global styles.
3
+ */
1 4
 :global {
5
+    /* No border on responsive table. */
2 6
     @media (max-width: 767px) {
3 7
         .table-responsive {
4 8
             border: none;

app/styles/common/hacks.scss → app/common/styles/hacks.scss View File

@@ -1,5 +1,8 @@
1
+/**
2
+ * Hacks for specific browsers and bugfixes.
3
+ */
1 4
 :global {
2
-    /* Firefox hack for responsive table */
5
+    /* Firefox hack for responsive table in Bootstrap */
3 6
     @-moz-document url-prefix() {
4 7
         fieldset {
5 8
             display: table-cell;

app/styles/common/index.js → app/common/styles/index.js View File

@@ -1,2 +1,5 @@
1
+/**
2
+ * Common styles modifications and hacks.
3
+ */
1 4
 export * from "./hacks.scss";
2 5
 export * from "./common.scss";

+ 5
- 0
app/common/utils/index.js View File

@@ -0,0 +1,5 @@
1
+/**
2
+ * Prototype modifications, common utils loaded before the main script
3
+ */
4
+export * from "./jquery";
5
+export * from "./string";

app/utils/common/jquery.js → app/common/utils/jquery.js View File

@@ -1,9 +1,16 @@
1
+/**
2
+ * jQuery prototype extensions.
3
+ */
4
+
5
+
1 6
 /**
2 7
  * Shake animation.
3 8
  *
4 9
  * @param   intShakes   Number of times to shake.
5 10
  * @param   intDistance Distance to move the object.
6 11
  * @param   intDuration Duration of the animation.
12
+ *
13
+ * @return  The element it was applied one, for chaining.
7 14
  */
8 15
 $.fn.shake = function(intShakes, intDistance, intDuration) {
9 16
     this.each(function() {

app/utils/common/string.js → app/common/utils/string.js View File

@@ -1,14 +1,23 @@
1 1
 /**
2
- * Capitalize function on strings.
2
+ * String prototype extension.
3
+ */
4
+
5
+
6
+/**
7
+ * Capitalize a string.
8
+ *
9
+ * @return Capitalized string.
3 10
  */
4 11
 String.prototype.capitalize = function () {
5 12
     return this.charAt(0).toUpperCase() + this.slice(1);
6 13
 };
7 14
 
15
+
8 16
 /**
9 17
  * Strip characters at the end of a string.
10 18
  *
11 19
  * @param   chars   A regex-like element to strip from the end.
20
+ * @return  Stripped string.
12 21
  */
13 22
 String.prototype.rstrip = function (chars) {
14 23
     let regex = new RegExp(chars + "$");

+ 17
- 19
app/components/Album.jsx View File

@@ -1,17 +1,26 @@
1
+// NPM import
1 2
 import React, { Component, PropTypes } from "react";
2 3
 import CSSModules from "react-css-modules";
3 4
 import { defineMessages, FormattedMessage, injectIntl, intlShape } from "react-intl";
4 5
 import FontAwesome from "react-fontawesome";
5 6
 import Immutable from "immutable";
6 7
 
8
+// Local imports
7 9
 import { formatLength, messagesMap } from "../utils";
8 10
 
11
+// Translations
9 12
 import commonMessages from "../locales/messagesDescriptors/common";
10 13
 
14
+// Styles
11 15
 import css from "../styles/Album.scss";
12 16
 
17
+// Set translations
13 18
 const albumMessages = defineMessages(messagesMap(Array.concat([], commonMessages)));
14 19
 
20
+
21
+/**
22
+ * Track row in an album tracks table.
23
+ */
15 24
 class AlbumTrackRowCSSIntl extends Component {
16 25
     render () {
17 26
         const { formatMessage } = this.props.intl;
@@ -33,19 +42,21 @@ class AlbumTrackRowCSSIntl extends Component {
33 42
         );
34 43
     }
35 44
 }
36
-
37 45
 AlbumTrackRowCSSIntl.propTypes = {
38 46
     playAction: PropTypes.func.isRequired,
39 47
     track: PropTypes.instanceOf(Immutable.Map).isRequired,
40 48
     intl: intlShape.isRequired
41 49
 };
42
-
43 50
 export let AlbumTrackRow = injectIntl(CSSModules(AlbumTrackRowCSSIntl, css));
44 51
 
45 52
 
53
+/**
54
+ * Tracks table of an album.
55
+ */
46 56
 class AlbumTracksTableCSS extends Component {
47 57
     render () {
48 58
         let rows = [];
59
+        // Build rows for each track
49 60
         const playAction = this.props.playAction;
50 61
         this.props.tracks.forEach(function (item) {
51 62
             rows.push(<AlbumTrackRow playAction={playAction} track={item} key={item.get("id")} />);
@@ -59,14 +70,16 @@ class AlbumTracksTableCSS extends Component {
59 70
         );
60 71
     }
61 72
 }
62
-
63 73
 AlbumTracksTableCSS.propTypes = {
64 74
     playAction: PropTypes.func.isRequired,
65 75
     tracks: PropTypes.instanceOf(Immutable.List).isRequired
66 76
 };
67
-
68 77
 export let AlbumTracksTable = CSSModules(AlbumTracksTableCSS, css);
69 78
 
79
+
80
+/**
81
+ * An entire album row containing art and tracks table.
82
+ */
70 83
 class AlbumRowCSS extends Component {
71 84
     render () {
72 85
         return (
@@ -88,24 +101,9 @@ class AlbumRowCSS extends Component {
88 101
         );
89 102
     }
90 103
 }
91
-
92 104
 AlbumRowCSS.propTypes = {
93 105
     playAction: PropTypes.func.isRequired,
94 106
     album: PropTypes.instanceOf(Immutable.Map).isRequired,
95 107
     songs: PropTypes.instanceOf(Immutable.List).isRequired
96 108
 };
97
-
98 109
 export let AlbumRow = CSSModules(AlbumRowCSS, css);
99
-
100
-export default class Album extends Component {
101
-    render () {
102
-        return (
103
-            <AlbumRow album={this.props.album} songs={this.props.songs} />
104
-        );
105
-    }
106
-}
107
-
108
-Album.propTypes = {
109
-    album: PropTypes.instanceOf(Immutable.Map).isRequired,
110
-    songs: PropTypes.instanceOf(Immutable.List).isRequired
111
-};

+ 10
- 2
app/components/Albums.jsx View File

@@ -1,16 +1,24 @@
1
+// NPM imports
1 2
 import React, { Component, PropTypes } from "react";
2 3
 import Immutable from "immutable";
3 4
 
5
+// Local imports
4 6
 import FilterablePaginatedGrid from "./elements/Grid";
5 7
 import DismissibleAlert from "./elements/DismissibleAlert";
6 8
 
9
+
10
+/**
11
+ * Paginated albums grid
12
+ */
7 13
 export default class Albums extends Component {
8 14
     render () {
15
+        // Handle error
9 16
         let error = null;
10 17
         if (this.props.error) {
11 18
             error =  (<DismissibleAlert type="danger" text={this.props.error} />);
12 19
         }
13 20
 
21
+        // Set grid props
14 22
         const grid = {
15 23
             isFetching: this.props.isFetching,
16 24
             items: this.props.albums,
@@ -19,6 +27,7 @@ export default class Albums extends Component {
19 27
             subItemsType: "tracks",
20 28
             subItemsLabel: "app.common.track"
21 29
         };
30
+
22 31
         return (
23 32
             <div>
24 33
                 { error }
@@ -27,10 +36,9 @@ export default class Albums extends Component {
27 36
         );
28 37
     }
29 38
 }
30
-
31 39
 Albums.propTypes = {
32
-    isFetching: PropTypes.bool.isRequired,
33 40
     error: PropTypes.string,
41
+    isFetching: PropTypes.bool.isRequired,
34 42
     albums: PropTypes.instanceOf(Immutable.List).isRequired,
35 43
     pagination: PropTypes.object.isRequired,
36 44
 };

+ 30
- 25
app/components/Artist.jsx View File

@@ -1,57 +1,63 @@
1
+// NPM imports
1 2
 import React, { Component, PropTypes } from "react";
2 3
 import CSSModules from "react-css-modules";
3 4
 import { defineMessages, FormattedMessage } from "react-intl";
4 5
 import FontAwesome from "react-fontawesome";
5 6
 import Immutable from "immutable";
6 7
 
8
+// Local imports
7 9
 import { messagesMap } from "../utils/";
8 10
 
11
+// Other components
9 12
 import { AlbumRow } from "./Album";
10 13
 import DismissibleAlert from "./elements/DismissibleAlert";
11 14
 
15
+// Translations
12 16
 import commonMessages from "../locales/messagesDescriptors/common";
13 17
 
18
+// Styles
14 19
 import css from "../styles/Artist.scss";
15 20
 
21
+// Define translations
16 22
 const artistMessages = defineMessages(messagesMap(Array.concat([], commonMessages)));
17 23
 
24
+
25
+/**
26
+ * Single artist page
27
+ */
18 28
 class ArtistCSS extends Component {
19 29
     render () {
20
-        const loading = (
21
-            <div className="row text-center">
22
-                <p>
23
-                    <FontAwesome name="spinner" className="fa-pulse fa-3x fa-fw" aria-hidden="true" />
24
-                    <span className="sr-only"><FormattedMessage {...artistMessages["app.common.loading"]} /></span>
25
-                </p>
26
-            </div>
27
-        );
28
-
29
-        if (this.props.isFetching && !this.props.artist.size > 0) {
30
-            // Loading
31
-            return loading;
30
+        // Define loading message
31
+        let loading = null;
32
+        if (this.props.isFetching) {
33
+            loading = (
34
+                <div className="row text-center">
35
+                    <p>
36
+                        <FontAwesome name="spinner" className="fa-pulse fa-3x fa-fw" aria-hidden="true" />
37
+                        <span className="sr-only"><FormattedMessage {...artistMessages["app.common.loading"]} /></span>
38
+                    </p>
39
+                </div>
40
+            );
32 41
         }
33 42
 
43
+        // Handle error
34 44
         let error = null;
35 45
         if (this.props.error) {
36 46
             error =  (<DismissibleAlert type="danger" text={this.props.error} />);
37 47
         }
38 48
 
49
+        // Build album rows
39 50
         let albumsRows = [];
40 51
         const { albums, songs, playAction } = this.props;
41
-        const artistAlbums = this.props.artist.get("albums");
42
-        if (albums && songs && artistAlbums && artistAlbums.size > 0) {
43
-            this.props.artist.get("albums").forEach(function (album) {
44
-                album = albums.get(album);
52
+        if (albums && songs) {
53
+            albums.forEach(function (album) {
45 54
                 const albumSongs = album.get("tracks").map(
46 55
                     id => songs.get(id)
47 56
                 );
48 57
                 albumsRows.push(<AlbumRow playAction={playAction} album={album} songs={albumSongs} key={album.get("id")} />);
49 58
             });
50 59
         }
51
-        else {
52
-            // Loading
53
-            albumsRows = loading;
54
-        }
60
+
55 61
         return (
56 62
             <div>
57 63
                 { error }
@@ -70,18 +76,17 @@ class ArtistCSS extends Component {
70 76
                     </div>
71 77
                 </div>
72 78
                 { albumsRows }
79
+                { loading }
73 80
             </div>
74 81
         );
75 82
     }
76 83
 }
77
-
78 84
 ArtistCSS.propTypes = {
79
-    playAction: PropTypes.func.isRequired,
80
-    isFetching: PropTypes.bool.isRequired,
81 85
     error: PropTypes.string,
86
+    isFetching: PropTypes.bool.isRequired,
87
+    playAction: PropTypes.func.isRequired,
82 88
     artist: PropTypes.instanceOf(Immutable.Map),
83
-    albums: PropTypes.instanceOf(Immutable.Map),
89
+    albums: PropTypes.instanceOf(Immutable.List),
84 90
     songs: PropTypes.instanceOf(Immutable.Map)
85 91
 };
86
-
87 92
 export default CSSModules(ArtistCSS, css);

+ 11
- 5
app/components/Artists.jsx View File

@@ -1,16 +1,24 @@
1
+// NPM imports
1 2
 import React, { Component, PropTypes } from "react";
2 3
 import Immutable from "immutable";
3 4
 
5
+// Other components
4 6
 import FilterablePaginatedGrid from "./elements/Grid";
5 7
 import DismissibleAlert from "./elements/DismissibleAlert";
6 8
 
7
-class Artists extends Component {
9
+
10
+/**
11
+ * Paginated artists grid
12
+ */
13
+export default class Artists extends Component {
8 14
     render () {
15
+        // Handle error
9 16
         let error = null;
10 17
         if (this.props.error) {
11 18
             error =  (<DismissibleAlert type="danger" text={this.props.error} />);
12 19
         }
13 20
 
21
+        // Define grid props
14 22
         const grid = {
15 23
             isFetching: this.props.isFetching,
16 24
             items: this.props.artists,
@@ -19,6 +27,7 @@ class Artists extends Component {
19 27
             subItemsType: "albums",
20 28
             subItemsLabel: "app.common.album"
21 29
         };
30
+
22 31
         return (
23 32
             <div>
24 33
                 { error }
@@ -27,12 +36,9 @@ class Artists extends Component {
27 36
         );
28 37
     }
29 38
 }
30
-
31 39
 Artists.propTypes = {
32
-    isFetching: PropTypes.bool.isRequired,
33 40
     error: PropTypes.string,
41
+    isFetching: PropTypes.bool.isRequired,
34 42
     artists: PropTypes.instanceOf(Immutable.List).isRequired,
35 43
     pagination: PropTypes.object.isRequired,
36 44
 };
37
-
38
-export default Artists;

+ 1
- 0
app/components/Discover.jsx View File

@@ -1,3 +1,4 @@
1
+// TODO: Discover view is not done
1 2
 import React, { Component } from "react";
2 3
 import CSSModules from "react-css-modules";
3 4
 import FontAwesome from "react-fontawesome";

+ 46
- 12
app/components/Login.jsx View File

@@ -1,57 +1,87 @@
1
+// NPM imports
1 2
 import React, { Component, PropTypes } from "react";
2 3
 import CSSModules from "react-css-modules";
3 4
 import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl";
4 5
 import FontAwesome from "react-fontawesome";
5 6
 
7
+// Local imports
6 8
 import { i18nRecord } from "../models/i18n";
7 9
 import { messagesMap } from "../utils";
10
+
11
+// Translations
8 12
 import APIMessages from "../locales/messagesDescriptors/api";
9 13
 import messages from "../locales/messagesDescriptors/Login";
10 14
 
15
+// Styles
11 16
 import css from "../styles/Login.scss";
12 17
 
18
+// Define translations
13 19
 const loginMessages = defineMessages(messagesMap(Array.concat([], APIMessages, messages)));
14 20
 
21
+
22
+/**
23
+ * Login form component
24
+ */
15 25
 class LoginFormCSSIntl extends Component {
16 26
     constructor (props) {
17 27
         super(props);
18
-
19
-        this.handleSubmit = this.handleSubmit.bind(this);
28
+        this.handleSubmit = this.handleSubmit.bind(this);  // bind this to handleSubmit
20 29
     }
21 30
 
22
-    setError (formGroup, error) {
23
-        if (error) {
31
+    /**
32
+     * Set an error on a form element.
33
+     *
34
+     * @param   formGroup   A form element.
35
+     * @param   hasError       Whether or not an error should be set.
36
+     *
37
+     * @return  True if an error is set, false otherwise
38
+     */
39
+    setError (formGroup, hasError) {
40
+        if (hasError) {
41
+            // If error is true, then add error class
24 42
             formGroup.classList.add("has-error");
25 43
             formGroup.classList.remove("has-success");
26 44
             return true;
27 45
         }
46
+        // Else, drop it and put success class
28 47
         formGroup.classList.remove("has-error");
29 48
         formGroup.classList.add("has-success");
30 49
         return false;
31 50
     }
32 51
 
52
+    /**
53
+     * Form submission handler.
54
+     *
55
+     * @param   e   JS Event.
56
+     */
33 57
     handleSubmit (e) {
34 58
         e.preventDefault();
59
+
60
+        // Don't handle submit if already logging in
35 61
         if (this.props.isAuthenticating) {
36
-            // Don't handle submit if already logging in
37 62
             return;
38 63
         }
64
+
65
+        // Get field values
39 66
         const username = this.refs.username.value.trim();
40 67
         const password = this.refs.password.value.trim();
41 68
         const endpoint = this.refs.endpoint.value.trim();
42 69
         const rememberMe = this.refs.rememberMe.checked;
43 70
 
71
+        // Check for errors on each field
44 72
         let hasError = this.setError(this.refs.usernameFormGroup, !username);
45 73
         hasError |= this.setError(this.refs.passwordFormGroup, !password);
46 74
         hasError |= this.setError(this.refs.endpointFormGroup, !endpoint);
47 75
 
48 76
         if (!hasError) {
77
+            // Submit if no error is found
49 78
             this.props.onSubmit(username, password, endpoint, rememberMe);
50 79
         }
51 80
     }
52 81
 
53 82
     componentDidUpdate () {
54 83
         if (this.props.error) {
84
+            // On unsuccessful login, set error classes and shake the form
55 85
             $(this.refs.loginForm).shake(3, 10, 300);
56 86
             this.setError(this.refs.usernameFormGroup, this.props.error);
57 87
             this.setError(this.refs.passwordFormGroup, this.props.error);
@@ -61,18 +91,23 @@ class LoginFormCSSIntl extends Component {
61 91
 
62 92
     render () {
63 93
         const {formatMessage} = this.props.intl;
94
+
95
+        // Handle info message
64 96
         let infoMessage = this.props.info;
65 97
         if (this.props.info && this.props.info instanceof i18nRecord) {
66 98
             infoMessage = (
67 99
                 <FormattedMessage {...loginMessages[this.props.info.id]} values={ this.props.info.values} />
68 100
             );
69 101
         }
102
+
103
+        // Handle error message
70 104
         let errorMessage = this.props.error;
71 105
         if (this.props.error && this.props.error instanceof i18nRecord) {
72 106
             errorMessage = (
73 107
                 <FormattedMessage {...loginMessages[this.props.error.id]} values={ this.props.error.values} />
74 108
             );
75 109
         }
110
+
76 111
         return (
77 112
             <div>
78 113
                 {
@@ -135,7 +170,6 @@ class LoginFormCSSIntl extends Component {
135 170
         );
136 171
     }
137 172
 }
138
-
139 173
 LoginFormCSSIntl.propTypes = {
140 174
     username: PropTypes.string,
141 175
     endpoint: PropTypes.string,
@@ -146,11 +180,13 @@ LoginFormCSSIntl.propTypes = {
146 180
     info: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(i18nRecord)]),
147 181
     intl: intlShape.isRequired,
148 182
 };
149
-
150 183
 export let LoginForm = injectIntl(CSSModules(LoginFormCSSIntl, css));
151 184
 
152 185
 
153
-class Login extends Component {
186
+/**
187
+ * Main login page, including title and login form.
188
+ */
189
+class LoginCSS extends Component {
154 190
     render () {
155 191
         const greeting = (
156 192
             <p>
@@ -169,8 +205,7 @@ class Login extends Component {
169 205
         );
170 206
     }
171 207
 }
172
-
173
-Login.propTypes = {
208
+LoginCSS.propTypes = {
174 209
     username: PropTypes.string,
175 210
     endpoint: PropTypes.string,
176 211
     rememberMe: PropTypes.bool,
@@ -179,5 +214,4 @@ Login.propTypes = {
179 214
     info: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
180 215
     error: PropTypes.oneOfType([PropTypes.string, PropTypes.object])
181 216
 };
182
-
183
-export default CSSModules(Login, css);
217
+export default CSSModules(LoginCSS, css);

+ 50
- 12
app/components/Songs.jsx View File

@@ -1,3 +1,4 @@
1
+// NPM imports
1 2
 import React, { Component, PropTypes } from "react";
2 3
 import { Link} from "react-router";
3 4
 import CSSModules from "react-css-modules";
@@ -6,24 +7,36 @@ import FontAwesome from "react-fontawesome";
6 7
 import Immutable from "immutable";
7 8
 import Fuse from "fuse.js";
8 9
 
10
+// Local imports
11
+import { formatLength, messagesMap } from "../utils";
12
+
13
+// Other components
9 14
 import DismissibleAlert from "./elements/DismissibleAlert";
10 15
 import FilterBar from "./elements/FilterBar";
11 16
 import Pagination from "./elements/Pagination";
12
-import { formatLength, messagesMap } from "../utils";
13 17
 
18
+// Translations
14 19
 import commonMessages from "../locales/messagesDescriptors/common";
15 20
 import messages from "../locales/messagesDescriptors/Songs";
16 21
 
22
+// Styles
17 23
 import css from "../styles/Songs.scss";
18 24
 
25
+// Define translations
19 26
 const songsMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
20 27
 
28
+
29
+/**
30
+ * A single row for a single song in the songs table.
31
+ */
21 32
 class SongsTableRowCSSIntl extends Component {
22 33
     render () {
23 34
         const { formatMessage } = this.props.intl;
35
+
24 36
         const length = formatLength(this.props.song.get("time"));
25 37
         const linkToArtist = "/artist/" + this.props.song.getIn(["artist", "id"]);
26 38
         const linkToAlbum = "/album/" + this.props.song.getIn(["album", "id"]);
39
+
27 40
         return (
28 41
             <tr>
29 42
                 <td>
@@ -43,18 +56,20 @@ class SongsTableRowCSSIntl extends Component {
43 56
         );
44 57
     }
45 58
 }
46
-
47 59
 SongsTableRowCSSIntl.propTypes = {
48 60
     playAction: PropTypes.func.isRequired,
49 61
     song: PropTypes.instanceOf(Immutable.Map).isRequired,
50 62
     intl: intlShape.isRequired
51 63
 };
52
-
53 64
 export let SongsTableRow = injectIntl(CSSModules(SongsTableRowCSSIntl, css));
54 65
 
55 66
 
67
+/**
68
+ * The songs table.
69
+ */
56 70
 class SongsTableCSS extends Component {
57 71
     render () {
72
+        // Handle filtering
58 73
         let displayedSongs = this.props.songs;
59 74
         if (this.props.filterText) {
60 75
             // Use Fuse for the filter
@@ -69,14 +84,16 @@ class SongsTableCSS extends Component {
69 84
             displayedSongs = displayedSongs.map(function (item) { return new Immutable.Map(item.item); });
70 85
         }
71 86
 
87
+        // Build song rows
72 88
         let rows = [];
73 89
         const { playAction } = this.props;
74 90
         displayedSongs.forEach(function (song) {
75 91
             rows.push(<SongsTableRow playAction={playAction} song={song} key={song.get("id")} />);
76 92
         });
93
+
94
+        // Handle login icon
77 95
         let loading = null;
78
-        if (rows.length == 0 && this.props.isFetching) {
79
-            // If we are fetching and there is nothing to show
96
+        if (this.props.isFetching) {
80 97
             loading = (
81 98
                 <p className="text-center">
82 99
                     <FontAwesome name="spinner" className="fa-pulse fa-3x fa-fw" aria-hidden="true" />
@@ -84,6 +101,7 @@ class SongsTableCSS extends Component {
84 101
                 </p>
85 102
             );
86 103
         }
104
+
87 105
         return (
88 106
             <div className="table-responsive">
89 107
                 <table className="table table-hover" styleName="songs">
@@ -114,26 +132,34 @@ class SongsTableCSS extends Component {
114 132
         );
115 133
     }
116 134
 }
117
-
118 135
 SongsTableCSS.propTypes = {
119 136
     playAction: PropTypes.func.isRequired,
120 137
     songs: PropTypes.instanceOf(Immutable.List).isRequired,
121 138
     filterText: PropTypes.string
122 139
 };
123
-
124 140
 export let SongsTable = CSSModules(SongsTableCSS, css);
125 141
 
126 142
 
143
+/**
144
+ * Complete songs table view with filter and pagination
145
+ */
127 146
 export default class FilterablePaginatedSongsTable extends Component {
128 147
     constructor (props) {
129 148
         super(props);
130 149
         this.state = {
131
-            filterText: ""
150
+            filterText: ""  // Initial state, no filter text
132 151
         };
133 152
 
134
-        this.handleUserInput = this.handleUserInput.bind(this);
153
+        this.handleUserInput = this.handleUserInput.bind(this);  // Bind this on user input handling
135 154
     }
136 155
 
156
+    /**
157
+     * Method called whenever the filter input is changed.
158
+     *
159
+     * Update the state accordingly.
160
+     *
161
+     * @param   filterText  Content of the filter input.
162
+     */
137 163
     handleUserInput (filterText) {
138 164
         this.setState({
139 165
             filterText: filterText
@@ -141,22 +167,34 @@ export default class FilterablePaginatedSongsTable extends Component {
141 167
     }
142 168
 
143 169
     render () {
170
+        // Handle error
144 171
         let error = null;
145 172
         if (this.props.error) {
146 173
             error =  (<DismissibleAlert type="danger" text={this.props.error} />);
147 174
         }
148 175
 
176
+        // Set props
177
+        const filterProps = {
178
+            filterText: this.state.filterText,
179
+            onUserInput: this.handleUserInput
180
+        };
181
+        const songsTableProps = {
182
+            playAction: this.props.playAction,
183
+            isFetching: this.props.isFetching,
184
+            songs: this.props.songs,
185
+            filterText: this.state.filterText
186
+        };
187
+
149 188
         return (
150 189
             <div>
151 190
                 { error }
152
-                <FilterBar filterText={this.state.filterText} onUserInput={this.handleUserInput} />
153
-                <SongsTable playAction={this.props.playAction} isFetching={this.props.isFetching} songs={this.props.songs} filterText={this.state.filterText} />
191
+                <FilterBar {...filterProps} />
192
+                <SongsTable {...songsTableProps} />
154 193
                 <Pagination {...this.props.pagination} />
155 194
             </div>
156 195
         );
157 196
     }
158 197
 }
159
-
160 198
 FilterablePaginatedSongsTable.propTypes = {
161 199
     playAction: PropTypes.func.isRequired,
162 200
     isFetching: PropTypes.bool.isRequired,

+ 7
- 1
app/components/elements/DismissibleAlert.jsx View File

@@ -1,11 +1,18 @@
1
+// NPM imports
1 2
 import React, { Component, PropTypes } from "react";
2 3
 
4
+
5
+/**
6
+ * A dismissible Bootstrap alert.
7
+ */
3 8
 export default class DismissibleAlert extends Component {
4 9
     render () {
10
+        // Set correct alert type
5 11
         let alertType = "alert-danger";
6 12
         if (this.props.type) {
7 13
             alertType = "alert-" + this.props.type;
8 14
         }
15
+
9 16
         return (
10 17
             <div className={["alert", alertType].join(" ")} role="alert">
11 18
                 <p>
@@ -18,7 +25,6 @@ export default class DismissibleAlert extends Component {
18 25
         );
19 26
     }
20 27
 }
21
-
22 28
 DismissibleAlert.propTypes = {
23 29
     type: PropTypes.string,
24 30
     text: PropTypes.string

+ 19
- 3
app/components/elements/FilterBar.jsx View File

@@ -1,28 +1,46 @@
1
+// NPM imports
1 2
 import React, { Component, PropTypes } from "react";
2 3
 import CSSModules from "react-css-modules";
3 4
 import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl";
4 5
 
6
+// Local imports
5 7
 import { messagesMap } from "../../utils";
8
+
9
+// Translations
6 10
 import messages from "../../locales/messagesDescriptors/elements/FilterBar";
7 11
 
12
+// Styles
8 13
 import css from "../../styles/elements/FilterBar.scss";
9 14
 
15
+// Define translations
10 16
 const filterMessages = defineMessages(messagesMap(Array.concat([], messages)));
11 17
 
18
+
19
+/**
20
+ * Filter bar element with input filter.
21
+ */
12 22
 class FilterBarCSSIntl extends Component {
13 23
     constructor (props) {
14 24
         super(props);
25
+        // Bind this on methods
15 26
         this.handleChange = this.handleChange.bind(this);
16 27
     }
17 28
 
29
+    /**
30
+     * Method to handle a change of filter input value.
31
+     *
32
+     * Calls the user input handler passed from parent component.
33
+     *
34
+     * @param   e   A JS event.
35
+     */
18 36
     handleChange (e) {
19 37
         e.preventDefault();
20
-
21 38
         this.props.onUserInput(this.refs.filterTextInput.value);
22 39
     }
23 40
 
24 41
     render () {
25 42
         const {formatMessage} = this.props.intl;
43
+
26 44
         return (
27 45
             <div styleName="filter">
28 46
                 <p className="col-xs-12 col-sm-6 col-md-4 col-md-offset-1" styleName="legend" id="filterInputDescription">
@@ -39,11 +57,9 @@ class FilterBarCSSIntl extends Component {
39 57
         );
40 58
     }
41 59
 }
42
-
43 60
 FilterBarCSSIntl.propTypes = {
44 61
     onUserInput: PropTypes.func,
45 62
     filterText: PropTypes.string,
46 63
     intl: intlShape.isRequired
47 64
 };
48
-
49 65
 export default injectIntl(CSSModules(FilterBarCSSIntl, css));

+ 90
- 39
app/components/elements/Grid.jsx View File

@@ -1,3 +1,4 @@
1
+// NPM imports
1 2
 import React, { Component, PropTypes } from "react";
2 3
 import { Link} from "react-router";
3 4
 import CSSModules from "react-css-modules";
@@ -9,32 +10,64 @@ import Isotope from "isotope-layout";
9 10
 import Fuse from "fuse.js";
10 11
 import shallowCompare from "react-addons-shallow-compare";
11 12
 
13
+// Local imports
14
+import { immutableDiff, messagesMap } from "../../utils/";
15
+
16
+// Other components
12 17
 import FilterBar from "./FilterBar";
13 18
 import Pagination from "./Pagination";
14
-import { immutableDiff, messagesMap } from "../../utils/";
15 19
 
20
+// Translations
16 21
 import commonMessages from "../../locales/messagesDescriptors/common";
17 22
 import messages from "../../locales/messagesDescriptors/grid";
18 23
 
24
+// Styles
19 25
 import css from "../../styles/elements/Grid.scss";
20 26
 
27
+// Define translations
21 28
 const gridMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
22 29
 
30
+// Constants
31
+const ISOTOPE_OPTIONS = {  /** Default options for Isotope grid layout. */
32
+    getSortData: {
33
+        name: ".name",
34
+        nSubitems: ".sub-items .n-sub-items"
35
+    },
36
+    transitionDuration: 0,
37
+    sortBy: "name",
38
+    itemSelector: ".grid-item",
39
+    percentPosition: true,
40
+    layoutMode: "fitRows",
41
+    filter: "*",
42
+    fitRows: {
43
+        gutter: 0
44
+    }
45
+};
46
+
47
+
48
+/**
49
+ * A single item in the grid, art + text under the art.
50
+ */
23 51
 class GridItemCSSIntl extends Component {
24 52
     render () {
25 53
         const {formatMessage} = this.props.intl;
26 54
 
55
+        // Get number of sub-items
27 56
         let nSubItems = this.props.item.get(this.props.subItemsType);
28 57
         if (Immutable.List.isList(nSubItems)) {
29 58
             nSubItems = nSubItems.size;
30 59
         }
31 60
 
32
-        let subItemsLabel = formatMessage(gridMessages[this.props.subItemsLabel], { itemCount: nSubItems });
61
+        // Define correct sub-items label (plural)
62
+        let subItemsLabel = formatMessage(
63
+            gridMessages[this.props.subItemsLabel],
64
+            { itemCount: nSubItems }
65
+        );
33 66
 
34 67
         const to = "/" + this.props.itemsType + "/" + this.props.item.get("id");
35 68
         const id = "grid-item-" + this.props.itemsType + "/" + this.props.item.get("id");
36
-
37 69
         const title = formatMessage(gridMessages["app.grid.goTo" + this.props.itemsType.capitalize() + "Page"]);
70
+
38 71
         return (
39 72
             <div className="grid-item col-xs-6 col-sm-3" styleName="placeholders" id={id}>
40 73
                 <div className="grid-item-content text-center">
@@ -46,7 +79,6 @@ class GridItemCSSIntl extends Component {
46 79
         );
47 80
     }
48 81
 }
49
-
50 82
 GridItemCSSIntl.propTypes = {
51 83
     item: PropTypes.instanceOf(Immutable.Map).isRequired,
52 84
     itemsType: PropTypes.string.isRequired,
@@ -55,26 +87,12 @@ GridItemCSSIntl.propTypes = {
55 87
     subItemsLabel: PropTypes.string.isRequired,
56 88
     intl: intlShape.isRequired
57 89
 };
58
-
59 90
 export let GridItem = injectIntl(CSSModules(GridItemCSSIntl, css));
60 91
 
61 92
 
62
-const ISOTOPE_OPTIONS = {  /** Default options for Isotope grid layout. */
63
-    getSortData: {
64
-        name: ".name",
65
-        nSubitems: ".sub-items .n-sub-items"
66
-    },
67
-    transitionDuration: 0,
68
-    sortBy: "name",
69
-    itemSelector: ".grid-item",
70
-    percentPosition: true,
71
-    layoutMode: "fitRows",
72
-    filter: "*",
73
-    fitRows: {
74
-        gutter: 0
75
-    }
76
-};
77
-
93
+/**
94
+ * A grid, formatted using Isotope.JS
95
+ */
78 96
 export class Grid extends Component {
79 97
     constructor (props) {
80 98
         super(props);
@@ -82,20 +100,29 @@ export class Grid extends Component {
82 100
         // Init grid data member
83 101
         this.iso = null;
84 102
 
103
+        // Bind this
104
+        this.createIsotopeContainer = this.createIsotopeContainer.bind(this);
85 105
         this.handleFiltering = this.handleFiltering.bind(this);
86 106
     }
87 107
 
108
+    /**
109
+     * Create an isotope container if none already exist.
110
+     */
88 111
     createIsotopeContainer () {
89 112
         if (this.iso == null) {
90 113
             this.iso = new Isotope(this.refs.grid, ISOTOPE_OPTIONS);
91 114
         }
92 115
     }
93 116
 
117
+    /**
118
+     * Handle filtering on the grid.
119
+     */
94 120
     handleFiltering (props) {
95 121
         // If no query provided, drop any filter in use
96 122
         if (props.filterText == "") {
97 123
             return this.iso.arrange(ISOTOPE_OPTIONS);
98 124
         }
125
+
99 126
         // Use Fuse for the filter
100 127
         let result = new Fuse(
101 128
             props.items.toJS(),
@@ -103,7 +130,8 @@ export class Grid extends Component {
103 130
                 "keys": ["name"],
104 131
                 "threshold": 0.4,
105 132
                 "include": ["score"]
106
-            }).search(props.filterText);
133
+            }
134
+        ).search(props.filterText);
107 135
 
108 136
         // Apply filter on grid
109 137
         this.iso.arrange({
@@ -130,10 +158,12 @@ export class Grid extends Component {
130 158
     }
131 159
 
132 160
     shouldComponentUpdate(nextProps, nextState) {
161
+        // Shallow comparison, render is pure
133 162
         return shallowCompare(this, nextProps, nextState);
134 163
     }
135 164
 
136 165
     componentWillReceiveProps(nextProps) {
166
+        // Handle filtering if filterText is changed
137 167
         if (nextProps.filterText !== this.props.filterText) {
138 168
             this.handleFiltering(nextProps);
139 169
         }
@@ -143,8 +173,7 @@ export class Grid extends Component {
143 173
         // Setup grid
144 174
         this.createIsotopeContainer();
145 175
         // Only arrange if there are elements to arrange
146
-        const length = this.props.items.length || 0;
147
-        if (length > 0) {
176
+        if (this.props.items.size > 0) {
148 177
             this.iso.arrange();
149 178
         }
150 179
     }
@@ -152,25 +181,31 @@ export class Grid extends Component {
152 181
     componentDidUpdate(prevProps) {
153 182
         // The list of keys seen in the previous render
154 183
         let currentKeys = prevProps.items.map(
155
-            (n) => "grid-item-" + prevProps.itemsType + "/" + n.get("id"));
184
+            (n) => "grid-item-" + prevProps.itemsType + "/" + n.get("id")
185
+        );
156 186
 
157 187
         // The latest list of keys that have been rendered
158 188
         const {itemsType} = this.props;
159 189
         let newKeys = this.props.items.map(
160
-            (n) => "grid-item-" + itemsType + "/" + n.get("id"));
190
+            (n) => "grid-item-" + itemsType + "/" + n.get("id")
191
+        );
161 192
 
162
-        // Find which keys are new between the current set of keys and any new children passed to this component
193
+        // Find which keys are new between the current set of keys and any new
194
+        // children passed to this component
163 195
         let addKeys = immutableDiff(newKeys, currentKeys);
164 196
 
165
-        // Find which keys have been removed between the current set of keys and any new children passed to this component
197
+        // Find which keys have been removed between the current set of keys
198
+        // and any new children passed to this component
166 199
         let removeKeys = immutableDiff(currentKeys, newKeys);
167 200
 
168 201
         let iso = this.iso;
169
-        if (removeKeys.count() > 0) {
202
+        // Remove removed items
203
+        if (removeKeys.size > 0) {
170 204
             removeKeys.forEach(removeKey => iso.remove(document.getElementById(removeKey)));
171 205
             iso.arrange();
172 206
         }
173
-        if (addKeys.count() > 0) {
207
+        // Add new items
208
+        if (addKeys.size > 0) {
174 209
             const itemsToAdd = addKeys.map((addKey) => document.getElementById(addKey)).toArray();
175 210
             iso.addItems(itemsToAdd);
176 211
             iso.arrange();
@@ -187,13 +222,9 @@ export class Grid extends Component {
187 222
     }
188 223
 
189 224
     render () {
190
-        let gridItems = [];
191
-        const { itemsType, itemsLabel, subItemsType, subItemsLabel } = this.props;
192
-        this.props.items.forEach(function (item) {
193
-            gridItems.push(<GridItem item={item} itemsType={itemsType} itemsLabel={itemsLabel} subItemsType={subItemsType} subItemsLabel={subItemsLabel} key={item.get("id")} />);
194
-        });
225
+        // Handle loading
195 226
         let loading = null;
196
-        if (gridItems.length == 0 && this.props.isFetching) {
227
+        if (this.props.isFetching) {
197 228
             loading = (
198 229
                 <div className="row text-center">
199 230
                     <p>
@@ -203,9 +234,16 @@ export class Grid extends Component {
203 234
                 </div>
204 235
             );
205 236
         }
237
+
238
+        // Build grid items
239
+        let gridItems = [];
240
+        const { itemsType, itemsLabel, subItemsType, subItemsLabel } = this.props;
241
+        this.props.items.forEach(function (item) {
242
+            gridItems.push(<GridItem item={item} itemsType={itemsType} itemsLabel={itemsLabel} subItemsType={subItemsType} subItemsLabel={subItemsLabel} key={item.get("id")} />);
243
+        });
244
+
206 245
         return (
207 246
             <div>
208
-                { loading }
209 247
                 <div className="row">
210 248
                     <div className="grid" ref="grid">
211 249
                         {/* Sizing element */}
@@ -214,11 +252,11 @@ export class Grid extends Component {
214 252
                         { gridItems }
215 253
                     </div>
216 254
                 </div>
255
+                { loading }
217 256
             </div>
218 257
         );
219 258
     }
220 259
 }
221
-
222 260
 Grid.propTypes = {
223 261
     isFetching: PropTypes.bool.isRequired,
224 262
     items: PropTypes.instanceOf(Immutable.List).isRequired,
@@ -229,16 +267,29 @@ Grid.propTypes = {
229 267
     filterText: PropTypes.string
230 268
 };
231 269
 
270
+
271
+/**
272
+ * Full grid with pagination and filtering input.
273
+ */
232 274
 export default class FilterablePaginatedGrid extends Component {
233 275
     constructor (props) {
234 276
         super(props);
277
+
235 278
         this.state = {
236
-            filterText: ""
279
+            filterText: ""  // No filterText at init
237 280
         };
238 281
 
282
+        // Bind this
239 283
         this.handleUserInput = this.handleUserInput.bind(this);
240 284
     }
241 285
 
286
+    /**
287
+     * Method called whenever the filter input is changed.
288
+     *
289
+     * Update the state accordingly.
290
+     *
291
+     * @param   filterText  Content of the filter input.
292
+     */
242 293
     handleUserInput (filterText) {
243 294
         this.setState({
244 295
             filterText: filterText

+ 62
- 41
app/components/elements/Pagination.jsx View File

@@ -1,71 +1,90 @@
1
+// NPM imports
1 2
 import React, { Component, PropTypes } from "react";
2 3
 import { Link } from "react-router";
3 4
 import CSSModules from "react-css-modules";
4 5
 import { defineMessages, injectIntl, intlShape, FormattedMessage, FormattedHTMLMessage } from "react-intl";
5 6
 
6
-import { messagesMap } from "../../utils";
7
+// Local imports
8
+import { computePaginationBounds, filterInt, messagesMap } from "../../utils";
9
+
10
+// Translations
7 11
 import commonMessages from "../../locales/messagesDescriptors/common";
8 12
 import messages from "../../locales/messagesDescriptors/elements/Pagination";
9 13
 
14
+// Styles
10 15
 import css from "../../styles/elements/Pagination.scss";
11 16
 
17
+// Define translations
12 18
 const paginationMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
13 19
 
20
+
21
+/**
22
+ * Pagination button bar
23
+ */
14 24
 class PaginationCSSIntl extends Component {
15
-    computePaginationBounds(currentPage, nPages, maxNumberPagesShown=5) {
16
-        // Taken from http://stackoverflow.com/a/8608998/2626416
17
-        let lowerLimit = currentPage;
18
-        let upperLimit = currentPage;
19
-
20
-        for (let b = 1; b < maxNumberPagesShown && b < nPages;) {
21
-            if (lowerLimit > 1 ) {
22
-                lowerLimit--;
23
-                b++;
24
-            }
25
-            if (b < maxNumberPagesShown && upperLimit < nPages) {
26
-                upperLimit++;
27
-                b++;
28
-            }
29
-        }
25
+    constructor (props) {
26
+        super (props);
30 27
 
31
-        return {
32
-            lowerLimit: lowerLimit,
33
-            upperLimit: upperLimit + 1  // +1 to ease iteration in for with <
34
-        };
28
+        // Bind this
29
+        this.goToPage = this.goToPage.bind(this);
30
+        this.dotsOnClick = this.dotsOnClick.bind(this);
31
+        this.dotsOnKeyDown = this.dotsOnKeyDown.bind(this);
32
+        this.cancelModalBox = this.cancelModalBox.bind(this);
35 33
     }
36 34
 
37
-    goToPage(ev) {
38
-        ev.preventDefault();
39
-        const pageNumber = parseInt(this.refs.pageInput.value);
40
-        $(this.refs.paginationModal).modal("hide");
41
-        if (pageNumber) {
35
+    /**
36
+     * Handle click on the "go to page" button in the modal.
37
+     */
38
+    goToPage(e) {
39
+        e.preventDefault();
40
+
41
+        // Parse and check page number
42
+        const pageNumber = filterInt(this.refs.pageInput.value);
43
+        if (pageNumber && !isNaN(pageNumber)) {
44
+            // Hide the modal and go to page
45
+            $(this.refs.paginationModal).modal("hide");
42 46
             this.props.goToPage(pageNumber);
43 47
         }
44 48
     }
45 49
 
50
+    /**
51
+     * Handle click on the ellipsis dots.
52
+     */
46 53
     dotsOnClick() {
54
+        // Show modal
47 55
         $(this.refs.paginationModal).modal();
48 56
     }
49 57
 
50
-    dotsOnKeyDown(ev) {
51
-        ev.preventDefault;
52
-        const code = ev.keyCode || ev.which;
58
+    /**
59
+     * Bind key down events on ellipsis dots for a11y.
60
+     */
61
+    dotsOnKeyDown(e) {
62
+        e.preventDefault;
63
+        const code = e.keyCode || e.which;
53 64
         if (code == 13 || code == 32) {  // Enter or Space key
54 65
             this.dotsOnClick();  // Fire same event as onClick
55 66
         }
56 67
     }
57 68
 
69
+    /**
70
+     * Handle click on "cancel" in the modal box.
71
+     */
58 72
     cancelModalBox() {
73
+        // Hide modal
59 74
         $(this.refs.paginationModal).modal("hide");
60 75
     }
61 76
 
62 77
     render () {
63 78
         const { formatMessage } = this.props.intl;
64
-        const { lowerLimit, upperLimit } = this.computePaginationBounds(this.props.currentPage, this.props.nPages);
79
+
80
+        // Get bounds
81
+        const { lowerLimit, upperLimit } = computePaginationBounds(this.props.currentPage, this.props.nPages);
82
+        // Store buttons
65 83
         let pagesButton = [];
66 84
         let key = 0;  // key increment to ensure correct ordering
85
+
86
+        // If lower limit is above 1, push 1 and ellipsis
67 87
         if (lowerLimit > 1) {
68
-            // Push first page
69 88
             pagesButton.push(
70 89
                 <li className="page-item" key={key}>
71 90
                     <Link className="page-link" title={formatMessage(paginationMessages["app.pagination.goToPageWithoutMarkup"], { pageNumber: 1})} to={this.props.buildLinkToPage(1)}>
@@ -73,27 +92,28 @@ class PaginationCSSIntl extends Component {
73 92
                     </Link>
74 93
                 </li>
75 94
             );
76
-            key++;
95
+            key++;  // Always increment key after a push
77 96
             if (lowerLimit > 2) {
78 97
                 // Eventually push "…"
79 98
                 pagesButton.push(
80 99
                     <li className="page-item" key={key}>
81
-                        <span tabIndex="0" role="button" onKeyDown={this.dotsOnKeyDown.bind(this)} onClick={this.dotsOnClick.bind(this)}>&hellip;</span>
100
+                        <span tabIndex="0" role="button" onKeyDown={this.dotsOnKeyDown} onClick={this.dotsOnClick}>&hellip;</span>
82 101
                     </li>
83 102
                 );
84 103
                 key++;
85 104
             }
86 105
         }
106
+        // Main buttons, between lower and upper limits
87 107
         for (let i = lowerLimit; i < upperLimit; i++) {
88
-            let className = "page-item";
108
+            let classNames = ["page-item"];
89 109
             let currentSpan = null;
90 110
             if (this.props.currentPage == i) {
91
-                className += " active";
111
+                classNames.push("active");
92 112
                 currentSpan = <span className="sr-only">(<FormattedMessage {...paginationMessages["app.pagination.current"]} />)</span>;
93 113
             }
94 114
             const title = formatMessage(paginationMessages["app.pagination.goToPageWithoutMarkup"], { pageNumber: i });
95 115
             pagesButton.push(
96
-                <li className={className} key={key}>
116
+                <li className={classNames.join(" ")} key={key}>
97 117
                     <Link className="page-link" title={title} to={this.props.buildLinkToPage(i)}>
98 118
                         <FormattedHTMLMessage {...paginationMessages["app.pagination.goToPage"]} values={{ pageNumber: i }} />
99 119
                         {currentSpan}
@@ -102,12 +122,13 @@ class PaginationCSSIntl extends Component {
102 122
             );
103 123
             key++;
104 124
         }
125
+        // If upper limit is below the total number of page, show last page button
105 126
         if (upperLimit < this.props.nPages) {
106 127
             if (upperLimit < this.props.nPages - 1) {
107 128
                 // Eventually push "…"
108 129
                 pagesButton.push(
109 130
                     <li className="page-item" key={key}>
110
-                        <span tabIndex="0" role="button" onKeyDown={this.dotsOnKeyDown.bind(this)} onClick={this.dotsOnClick.bind(this)}>&hellip;</span>
131
+                        <span tabIndex="0" role="button" onKeyDown={this.dotsOnKeyDown} onClick={this.dotsOnClick}>&hellip;</span>
111 132
                     </li>
112 133
                 );
113 134
                 key++;
@@ -122,6 +143,8 @@ class PaginationCSSIntl extends Component {
122 143
                 </li>
123 144
             );
124 145
         }
146
+
147
+        // If there are actually some buttons, show them
125 148
         if (pagesButton.length > 1) {
126 149
             return (
127 150
                 <div>
@@ -140,15 +163,15 @@ class PaginationCSSIntl extends Component {
140 163
                                     </h4>
141 164
                                 </div>
142 165
                                 <div className="modal-body">
143
-                                    <form onSubmit={this.goToPage.bind(this)}>
166
+                                    <form onSubmit={this.goToPage}>
144 167
                                         <input className="form-control" autoComplete="off" type="number" ref="pageInput" aria-label={formatMessage(paginationMessages["app.pagination.pageToGoTo"])} autoFocus />
145 168
                                     </form>
146 169
                                 </div>
147 170
                                 <div className="modal-footer">
148
-                                    <button type="button" className="btn btn-default" onClick={this.cancelModalBox.bind(this)}>
171
+                                    <button type="button" className="btn btn-default" onClick={this.cancelModalBox}>
149 172
                                         <FormattedMessage {...paginationMessages["app.common.cancel"]} />
150 173
                                     </button>
151
-                                    <button type="button" className="btn btn-primary" onClick={this.goToPage.bind(this)}>
174
+                                    <button type="button" className="btn btn-primary" onClick={this.goToPage}>
152 175
                                         <FormattedMessage {...paginationMessages["app.common.go"]} />
153 176
                                     </button>
154 177
                                 </div>
@@ -161,7 +184,6 @@ class PaginationCSSIntl extends Component {
161 184
         return null;
162 185
     }
163 186
 }
164
-
165 187
 PaginationCSSIntl.propTypes = {
166 188
     currentPage: PropTypes.number.isRequired,
167 189
     goToPage: PropTypes.func.isRequired,
@@ -169,5 +191,4 @@ PaginationCSSIntl.propTypes = {
169 191
     nPages: PropTypes.number.isRequired,
170 192
     intl: intlShape.isRequired,
171 193
 };
172
-
173 194
 export default injectIntl(CSSModules(PaginationCSSIntl, css));

+ 1
- 0
app/components/elements/WebPlayer.jsx View File

@@ -1,3 +1,4 @@
1
+// TODO: This file is to review
1 2
 import React, { Component, PropTypes } from "react";
2 3
 import CSSModules from "react-css-modules";
3 4
 import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl";

+ 19
- 14
app/components/layouts/Sidebar.jsx View File

@@ -1,21 +1,34 @@
1
+// NPM imports
1 2
 import React, { Component, PropTypes } from "react";
2 3
 import { IndexLink, Link} from "react-router";
3 4
 import CSSModules from "react-css-modules";
4 5
 import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl";
5 6
 
7
+// Local imports
6 8
 import { messagesMap } from "../../utils";
9
+
10
+// Other components
11
+/* import WebPlayer from "../../views/WebPlayer"; TODO */
12
+
13
+// Translations
7 14
 import commonMessages from "../../locales/messagesDescriptors/common";
8 15
 import messages from "../../locales/messagesDescriptors/layouts/Sidebar";
9 16
 
10
-import WebPlayer from "../../views/WebPlayer";
11
-
17
+// Styles
12 18
 import css from "../../styles/layouts/Sidebar.scss";
13 19
 
20
+// Define translations
14 21
 const sidebarLayoutMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
15 22
 
23
+
24
+/**
25
+ * Sidebar layout component, putting children next to the sidebar menu.
26
+ */
16 27
 class SidebarLayoutIntl extends Component {
17 28
     render () {
18 29
         const { formatMessage } = this.props.intl;
30
+
31
+        // Check active links
19 32
         const isActive = {
20 33
             discover: (this.props.location.pathname == "/discover") ? "active" : "link",
21 34
             browse: (this.props.location.pathname == "/browse") ? "active" : "link",
@@ -24,9 +37,12 @@ class SidebarLayoutIntl extends Component {
24 37
             songs: (this.props.location.pathname == "/songs") ? "active" : "link",
25 38
             search: (this.props.location.pathname == "/search") ? "active" : "link"
26 39
         };
40
+
41
+        // Hamburger collapsing function
27 42
         const collapseHamburger = function () {
28 43
             $("#main-navbar").collapse("hide");
29 44
         };
45
+
30 46
         return (
31 47
             <div>
32 48
                 <div className="row">
@@ -128,17 +144,9 @@ class SidebarLayoutIntl extends Component {
128 144
                                         </li>
129 145
                                     </ul>
130 146
                                 </li>
131
-                                <li>
132
-                                    <Link to="/search" title={formatMessage(sidebarLayoutMessages["app.sidebarLayout.search"])} styleName={isActive.search} onClick={collapseHamburger}>
133
-                                        <span className="glyphicon glyphicon-search" aria-hidden="true"></span>
134
-                                        <span className="hidden-md">
135
-                                            &nbsp;<FormattedMessage {...sidebarLayoutMessages["app.sidebarLayout.search"]} />
136
-                                        </span>
137
-                                    </Link>
138
-                                </li>
139 147
                             </ul>
140 148
                         </nav>
141
-                        <WebPlayer />
149
+                        { /** TODO <WebPlayer /> */ }
142 150
                     </div>
143 151
                 </div>
144 152
 
@@ -149,11 +157,8 @@ class SidebarLayoutIntl extends Component {
149 157
         );
150 158
     }
151 159
 }
152
-
153
-
154 160
 SidebarLayoutIntl.propTypes = {
155 161
     children: PropTypes.node,
156 162
     intl: intlShape.isRequired
157 163
 };
158
-
159 164
 export default injectIntl(CSSModules(SidebarLayoutIntl, css));

+ 5
- 0
app/components/layouts/Simple.jsx View File

@@ -1,5 +1,10 @@
1
+// NPM imports
1 2
 import React, { Component } from "react";
2 3
 
4
+
5
+/**
6
+ * Simple layout, meaning just enclosing children in a div.
7
+ */
3 8
 export default class SimpleLayout extends Component {
4 9
     render () {
5 10
         return (

+ 6
- 3
app/containers/App.jsx View File

@@ -1,12 +1,15 @@
1
+/**
2
+ * Main container at the top of our application components tree.
3
+ *
4
+ * Just a div wrapper around children for now.
5
+ */
1 6
 import React, { Component, PropTypes } from "react";
2 7
 
3 8
 export default class App extends Component {
4 9
     render () {
5 10
         return (
6 11
             <div>
7
-                {this.props.children && React.cloneElement(this.props.children, {
8
-                    error: this.props.error
9
-                })}
12
+                {this.props.children}
10 13
             </div>
11 14
         );
12 15
     }

+ 18
- 5
app/containers/RequireAuthentication.js View File

@@ -1,18 +1,31 @@
1
+/**
2
+ * Container wrapping elements neeeding a valid session. Automatically
3
+ * redirects to login form in case such session does not exist.
4
+ */
1 5
 import React, { Component, PropTypes } from "react";
2 6
 import { connect } from "react-redux";
3 7
 
4
-// TODO: Handle expired session
8
+
5 9
 export class RequireAuthentication extends Component {
6 10
     componentWillMount () {
11
+        // Check authentication on mount
7 12
         this.checkAuth(this.props.isAuthenticated);
8 13
     }
9 14
 
10 15
     componentWillUpdate (newProps) {
16
+        // Check authentication on update
11 17
         this.checkAuth(newProps.isAuthenticated);
12 18
     }
13 19
 
20
+    /**
21
+     * Handle redirection in case user is not authenticated.
22
+     *
23
+     * @param   isAuthenticated     A boolean stating whether user has a valid
24
+     *                              session or not.
25
+     */
14 26
     checkAuth (isAuthenticated) {
15 27
         if (!isAuthenticated) {
28
+            // Redirect to login, redirecting to the actual page after login.
16 29
             this.context.router.replace({
17 30
                 pathname: "/login",
18 31
                 state: {
@@ -26,10 +39,10 @@ export class RequireAuthentication extends Component {
26 39
     render () {
27 40
         return (
28 41
             <div>
29
-            {this.props.isAuthenticated === true
30
-                ? this.props.children
31
-                : null
32
-            }
42
+                {this.props.isAuthenticated === true
43
+                    ? this.props.children
44
+                    : null
45
+                }
33 46
             </div>
34 47
         );
35 48
     }

+ 3
- 0
app/containers/Root.jsx View File

@@ -1,3 +1,6 @@
1
+/**
2
+ * Root component to render, setting locale, messages, Router and Store.
3
+ */
1 4
 import React, { Component, PropTypes } from "react";
2 5
 import { Provider } from "react-redux";
3 6
 import { Router } from "react-router";

+ 0
- 1
app/locales/en-US/index.js View File

@@ -39,7 +39,6 @@ module.exports = {
39 39
     "app.sidebarLayout.home": "Home",  // Home
40 40
     "app.sidebarLayout.logout": "Logout",  // Logout
41 41
     "app.sidebarLayout.mainNavigationMenu": "Main navigation menu",  // ARIA label for the main navigation menu
42
-    "app.sidebarLayout.search": "Search",  // Search
43 42
     "app.sidebarLayout.settings": "Settings",  // Settings
44 43
     "app.sidebarLayout.toggleNavigation": "Toggle navigation",  // Screen reader description of toggle navigation button
45 44
     "app.songs.genre": "Genre",  // Genre (song)

+ 0
- 1
app/locales/fr-FR/index.js View File

@@ -39,7 +39,6 @@ module.exports = {
39 39
     "app.sidebarLayout.home": "Accueil",  // Home
40 40
     "app.sidebarLayout.logout": "Déconnexion",  // Logout
41 41
     "app.sidebarLayout.mainNavigationMenu": "Menu principal",  // ARIA label for the main navigation menu
42
-    "app.sidebarLayout.search": "Rechercher",  // Search
43 42
     "app.sidebarLayout.settings": "Préférences",  // Settings
44 43
     "app.sidebarLayout.toggleNavigation": "Afficher le menu",  // Screen reader description of toggle navigation button
45 44
     "app.songs.genre": "Genre",  // Genre (song)

+ 1
- 0
app/locales/index.js View File

@@ -1,3 +1,4 @@
1
+// Export all the existing locales
1 2
 module.exports = {
2 3
     "en-US": require("./en-US"),
3 4
     "fr-FR": require("./fr-FR")

+ 0
- 5
app/locales/messagesDescriptors/layouts/Sidebar.js View File

@@ -44,11 +44,6 @@ const messages = [
44 44
         description: "Browse songs",
45 45
         defaultMessage: "Browse songs"
46 46
     },
47
-    {
48
-        id: "app.sidebarLayout.search",
49
-        description: "Search",
50
-        defaultMessage: "Search"
51
-    },
52 47
     {
53 48
         id: "app.sidebarLayout.toggleNavigation",
54 49
         description: "Screen reader description of toggle navigation button",

+ 74
- 17
app/middleware/api.js View File

@@ -1,3 +1,9 @@
1
+/**
2
+ * Redux middleware to perform API queries.
3
+ *
4
+ * This middleware catches the API requests and replaces them with API
5
+ * responses.
6
+ */
1 7
 import fetch from "isomorphic-fetch";
2 8
 import humps from "humps";
3 9
 import X2JS from "x2js";
@@ -10,9 +16,19 @@ import { loginUserExpired } from "../actions/auth";
10 16
 export const API_VERSION = 350001;  /** API version to use. */
11 17
 export const BASE_API_PATH = "/server/xml.server.php";  /** Base API path after endpoint. */
12 18
 
19
+// Action key that carries API call info interpreted by this Redux middleware.
20
+export const CALL_API = "CALL_API";
21
+
13 22
 // Error class to represents errors from these actions.
14 23
 class APIError extends Error {}
15 24
 
25
+
26
+/**
27
+ * Check the HTTP status of the response.
28
+ *
29
+ * @param   response    A XHR response object.
30
+ * @return  The response or a rejected Promise if the check failed.
31
+ */
16 32
 function _checkHTTPStatus (response) {
17 33
     if (response.status >= 200 && response.status < 300) {
18 34
         return response;
@@ -21,10 +37,17 @@ function _checkHTTPStatus (response) {
21 37
     }
22 38
 }
23 39
 
40
+
41
+/**
42
+ * Parse the XML resulting from the API to JS object.
43
+ *
44
+ * @param   responseText    The text from the API response.
45
+ * @return  The response as a JS object or a rejected Promise on error.
46
+ */
24 47
 function _parseToJSON (responseText) {
25 48
     let x2js = new X2JS({
26
-        attributePrefix: "",
27
-        keepCData: false
49
+        attributePrefix: "",  // No prefix for attributes
50
+        keepCData: false  // Do not store __cdata and toString functions
28 51
     });
29 52
     if (responseText) {
30 53
         return x2js.xml_str2json(responseText).root;
@@ -35,6 +58,13 @@ function _parseToJSON (responseText) {
35 58
     }));
36 59
 }
37 60
 
61
+
62
+/**
63
+ * Check the errors returned by the API itself, in its response.
64
+ *
65
+ * @param   jsonData  A JS object representing the API response.
66
+ * @return  The input data or a rejected Promise if errors are present.
67
+ */
38 68
 function _checkAPIErrors (jsonData) {
39 69
     if (jsonData.error) {
40 70
         return Promise.reject(jsonData.error);
@@ -48,7 +78,15 @@ function _checkAPIErrors (jsonData) {
48 78
     return jsonData;
49 79
 }
50 80
 
81
+
82
+/**
83
+ * Apply some fixes on the API data.
84
+ *
85
+ * @param   jsonData    A JS object representing the API response.
86
+ * @return  A fixed JS object.
87
+ */
51 88
 function _uglyFixes (jsonData) {
89
+    // Fix songs array
52 90
     let _uglyFixesSongs = function (songs) {
53 91
         return songs.map(function (song) {
54 92
             // Fix for cdata left in artist and album
@@ -58,9 +96,10 @@ function _uglyFixes (jsonData) {
58 96
         });
59 97
     };
60 98
 
99
+    // Fix albums array
61 100
     let _uglyFixesAlbums = function (albums) {
62 101
         return albums.map(function (album) {
63
-            // TODO
102
+            // TODO: Should go in Ampache core
64 103
             // Fix for absence of distinction between disks in the same album
65 104
             if (album.disk > 1) {
66 105
                 album.name = album.name + " [Disk " + album.disk + "]";
@@ -75,13 +114,14 @@ function _uglyFixes (jsonData) {
75 114
                     album.tracks = [album.tracks];
76 115
                 }
77 116
 
78
-                // Fix tracks
117
+                // Fix tracks array
79 118
                 album.tracks = _uglyFixesSongs(album.tracks);
80 119
             }
81 120
             return album;
82 121
         });
83 122
     };
84 123
 
124
+    // Fix artists array
85 125
     let _uglyFixesArtists = function (artists) {
86 126
         return artists.map(function (artist) {
87 127
             // Move albums one node top
@@ -131,17 +171,15 @@ function _uglyFixes (jsonData) {
131 171
 
132 172
     // Fix albums
133 173
     if (jsonData.album) {
134
-        // Fix albums
135 174
         jsonData.album = _uglyFixesAlbums(jsonData.album);
136 175
     }
137 176
 
138 177
     // Fix songs
139 178
     if (jsonData.song) {
140
-        // Fix songs
141 179
         jsonData.song = _uglyFixesSongs(jsonData.song);
142 180
     }
143 181
 
144
-    // TODO
182
+    // TODO: Should go in Ampache core
145 183
     // Add sessionExpire information
146 184
     if (!jsonData.sessionExpire) {
147 185
         // Fix for Ampache not returning updated sessionExpire
@@ -151,17 +189,31 @@ function _uglyFixes (jsonData) {
151 189
     return jsonData;
152 190
 }
153 191
 
154
-// Fetches an API response and normalizes the result JSON according to schema.
155
-// This makes every API response have the same shape, regardless of how nested it was.
192
+
193
+/**
194
+ * Fetches an API response and normalizes the result.
195
+ *
196
+ * @param   endpoint    Base URL of your Ampache server.
197
+ * @param   action      API action name.
198
+ * @param   auth        API token to use.
199
+ * @param   username    Username to use in the API.
200
+ * @param   extraParams An object of extra parameters to pass to the API.
201
+ *
202
+ * @return  A fetching Promise.
203
+ */
156 204
 function doAPICall (endpoint, action, auth, username, extraParams) {
205
+    // Translate the API action to real API action
157 206
     const APIAction = extraParams.filter ? action.rstrip("s") : action;
207
+    // Set base params
158 208
     const baseParams = {
159 209
         version: API_VERSION,
160 210
         action: APIAction,
161 211
         auth: auth,
162 212
         user: username
163 213
     };
214
+    // Extend with extraParams
164 215
     const params = Object.assign({}, baseParams, extraParams);
216
+    // Assemble the full URL with endpoint, API path and GET params
165 217
     const fullURL = assembleURLAndParams(endpoint + BASE_API_PATH, params);
166 218
 
167 219
     return fetch(fullURL, {
@@ -175,19 +227,19 @@ function doAPICall (endpoint, action, auth, username, extraParams) {
175 227
         .then(_uglyFixes);
176 228
 }
177 229
 
178
-// Action key that carries API call info interpreted by this Redux middleware.
179
-export const CALL_API = "CALL_API";
180 230
 
181
-// A Redux middleware that interprets actions with CALL_API info specified.
182
-// Performs the call and promises when such actions are dispatched.
231
+/**
232
+ * A Redux middleware that interprets actions with CALL_API info specified.
233
+ * Performs the call and promises when such actions are dispatched.
234
+ */
183 235
 export default store => next => reduxAction => {
184 236
     if (reduxAction.type !== CALL_API) {
185
-        // Do not apply on every action
237
+        // Do not apply on other actions
186 238
         return next(reduxAction);
187 239
     }
188 240
 
241
+    // Check payload
189 242
     const { endpoint, action, auth, username, dispatch, extraParams } = reduxAction.payload;
190
-
191 243
     if (!endpoint || typeof endpoint !== "string") {
192 244
         throw new APIError("Specify a string endpoint URL.");
193 245
     }
@@ -207,22 +259,27 @@ export default store => next => reduxAction => {
207 259
         throw new APIError("Expected action to dispatch to be functions or null.");
208 260
     }
209 261
 
262
+    // Get the actions to dispatch
210 263
     const [ requestDispatch, successDispatch, failureDispatch ] = dispatch;<