@saltcorn/mobile-app 0.8.7 → 0.8.8-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@saltcorn/mobile-app",
3
3
  "displayName": "Saltcorn mobile app",
4
- "version": "0.8.7",
4
+ "version": "0.8.8-beta.1",
5
5
  "description": "Apache Cordova application with @saltcorn/markup",
6
6
  "main": "index.js",
7
7
  "scripts": {
@@ -1,15 +1,23 @@
1
- /*global i18next, saltcorn*/
1
+ /*global i18next, saltcorn, currentLocation*/
2
2
 
3
3
  function MobileRequest({
4
4
  xhr = false,
5
5
  files = undefined,
6
6
  query = undefined,
7
+ refererRoute = undefined,
7
8
  } = {}) {
8
9
  const cfg = saltcorn.data.state.getState().mobileConfig;
9
10
  const roleId = cfg.role_id ? cfg.role_id : 100;
10
11
  const userId = cfg.user_id ? cfg.user_id : undefined;
11
12
  const flashMessages = [];
12
-
13
+ const refererPath = refererRoute ? refererRoute.route : undefined;
14
+ const referQuery =
15
+ refererPath && refererRoute.query
16
+ ? refererRoute.query.startsWith("?")
17
+ ? refererRoute.query
18
+ : `?${refererRoute.query}`
19
+ : "";
20
+ const referer = refererPath ? `${refererPath}${referQuery}` : undefined;
13
21
  return {
14
22
  __: (s, ...params) =>
15
23
  i18next.t(s, {
@@ -41,5 +49,8 @@ function MobileRequest({
41
49
  xhr,
42
50
  files,
43
51
  query,
52
+ headers: {
53
+ referer: referer,
54
+ },
44
55
  };
45
56
  }
@@ -6,6 +6,7 @@ const getHeaders = () => {
6
6
  const stdHeaders = [
7
7
  { css: `static_assets/${versionTag}/saltcorn.css` },
8
8
  { script: `static_assets/${versionTag}/saltcorn-common.js` },
9
+ { script: `static_assets/${versionTag}/dayjs.min.js` },
9
10
  { script: "js/utils/iframe_view_utils.js" },
10
11
  ];
11
12
  return [...stdHeaders, ...config.pluginHeaders];
@@ -10,7 +10,7 @@ const postToggleField = async (context) => {
10
10
  if (isOfflineMode || localTableIds.indexOf(table.id) >= 0) {
11
11
  if (role_id > table.min_role_write)
12
12
  throw new saltcorn.data.utils.NotAuthorized(i18next.t("Not authorized"));
13
- await table.toggleBool(+id, field_name);
13
+ await table.toggleBool(+id, field_name); //TODO call with user
14
14
  if (isOfflineMode && !(await offlineHelper.getLastOfflineSession()))
15
15
  await offlineHelper.setOfflineSession({ offlineUser: user_name });
16
16
  } else {
@@ -1,4 +1,4 @@
1
- /*global MobileRequest, MobileResponse, parseQuery, wrapContents, saltcorn, offlineHelper*/
1
+ /*global MobileRequest, MobileResponse, parseQuery, wrapContents, saltcorn, offlineHelper, routingHistory*/
2
2
 
3
3
  /**
4
4
  *
@@ -76,7 +76,11 @@ const getView = async (context) => {
76
76
  const query = parseQuery(context.query);
77
77
  const { viewname } = context.params;
78
78
  const view = saltcorn.data.models.View.findOne({ name: viewname });
79
- const req = new MobileRequest({ xhr: context.xhr, query });
79
+ const refererRoute =
80
+ routingHistory?.length > 1
81
+ ? routingHistory[routingHistory.length - 2]
82
+ : undefined;
83
+ const req = new MobileRequest({ xhr: context.xhr, query, refererRoute });
80
84
  if (
81
85
  state.mobileConfig.role_id > view.min_role &&
82
86
  !(await view.authorise_get({ query, req, ...view }))
@@ -189,6 +189,15 @@ async function gotoEntryView() {
189
189
  }
190
190
  }
191
191
 
192
+ function handleOpenModal() {
193
+ const iframe = document.getElementById("content-iframe");
194
+ if (!iframe) return false;
195
+ const openModal = iframe.contentWindow.$("#scmodal.modal.show");
196
+ if (openModal.length === 0) return;
197
+ iframe.contentWindow.bootstrap.Modal.getInstance(openModal[0]).hide();
198
+ return true;
199
+ }
200
+
192
201
  async function handleRoute(route, query, files) {
193
202
  const mobileConfig = saltcorn.data.state.getState().mobileConfig;
194
203
  try {
@@ -201,7 +210,8 @@ async function handleRoute(route, query, files) {
201
210
  clearHistory();
202
211
  await gotoEntryView();
203
212
  } else {
204
- if (route === "/" || route === "get") return await gotoEntryView();
213
+ if (route === "/" || route === "get" || route === "get/")
214
+ return await gotoEntryView();
205
215
  const safeRoute = route ? route : currentLocation();
206
216
  addRoute({ route: safeRoute, query });
207
217
  const page = await router.resolve({
@@ -218,6 +228,7 @@ async function handleRoute(route, query, files) {
218
228
  : [],
219
229
  });
220
230
  if (page.redirect) {
231
+ if (handleOpenModal()) return;
221
232
  if (
222
233
  page.redirect.startsWith("http://localhost") ||
223
234
  page.redirect === "undefined"
@@ -20,8 +20,13 @@ function combineFormAndQuery(form, query) {
20
20
  * @param {*} url
21
21
  */
22
22
  async function execLink(url) {
23
- const { path, query } = parent.splitPathQuery(url);
24
- await parent.handleRoute(`get${path}`, query);
23
+ try {
24
+ showLoadSpinner();
25
+ const { path, query } = parent.splitPathQuery(url);
26
+ await parent.handleRoute(`get${path}`, query);
27
+ } finally {
28
+ removeLoadSpinner();
29
+ }
25
30
  }
26
31
 
27
32
  async function execNavbarLink(url) {
@@ -36,33 +41,39 @@ async function execNavbarLink(url) {
36
41
  * @returns
37
42
  */
38
43
  async function formSubmit(e, urlSuffix, viewname, noSubmitCb) {
39
- if (!noSubmitCb) e.submit();
40
- const files = {};
41
- const urlParams = new URLSearchParams();
42
- for (const entry of new FormData(e).entries()) {
43
- if (entry[1] instanceof File) files[entry[0]] = entry[1];
44
- else {
45
- // is there a hidden input with a filename?
46
- const domEl = $(e).find(
47
- `[name='${entry[0]}'][mobile-camera-input='true']`
48
- );
49
- if (domEl.length > 0) {
50
- const tokens = entry[1].split("/");
51
- const fileName = tokens[tokens.length - 1];
52
- const directory = tokens.splice(0, tokens.length - 1).join("/");
53
- // read and add file to submit
54
- const binary = await parent.readBinary(fileName, directory);
55
- files[entry[0]] = new File([binary], fileName);
56
- } else urlParams.append(entry[0], entry[1]);
44
+ try {
45
+ showLoadSpinner();
46
+ if (!noSubmitCb) e.submit();
47
+ const files = {};
48
+ const urlParams = new URLSearchParams();
49
+ for (const entry of new FormData(e).entries()) {
50
+ if (entry[1] instanceof File) files[entry[0]] = entry[1];
51
+ else {
52
+ // is there a hidden input with a filename?
53
+ const domEl = $(e).find(
54
+ `[name='${entry[0]}'][mobile-camera-input='true']`
55
+ );
56
+ if (domEl.length > 0) {
57
+ const tokens = entry[1].split("/");
58
+ const fileName = tokens[tokens.length - 1];
59
+ const directory = tokens.splice(0, tokens.length - 1).join("/");
60
+ // read and add file to submit
61
+ const binary = await parent.readBinary(fileName, directory);
62
+ files[entry[0]] = new File([binary], fileName);
63
+ } else urlParams.append(entry[0], entry[1]);
64
+ }
57
65
  }
66
+ const queryStr = urlParams.toString();
67
+ await parent.handleRoute(`post${urlSuffix}${viewname}`, queryStr, files);
68
+ } finally {
69
+ removeLoadSpinner();
58
70
  }
59
- const queryStr = urlParams.toString();
60
- await parent.handleRoute(`post${urlSuffix}${viewname}`, queryStr, files);
61
71
  }
62
72
 
63
73
  async function inline_local_submit(e, opts1) {
64
74
  try {
65
75
  e.preventDefault();
76
+ showLoadSpinner();
66
77
  const opts = JSON.parse(decodeURIComponent(opts1 || "") || "{}");
67
78
  const form = $(e.target).closest("form");
68
79
  const urlParams = new URLSearchParams();
@@ -82,25 +93,32 @@ async function inline_local_submit(e, opts1) {
82
93
  msg: error.message ? error.message : "An error occured.",
83
94
  },
84
95
  ]);
96
+ } finally {
97
+ removeLoadSpinner();
85
98
  }
86
99
  }
87
100
 
88
101
  async function saveAndContinue(e, action, k) {
89
- const form = $(e).closest("form");
90
- submitWithEmptyAction(form[0]);
91
- const queryStr = new URLSearchParams(new FormData(form[0])).toString();
92
- const res = await parent.router.resolve({
93
- pathname: `post${action}`,
94
- query: queryStr,
95
- xhr: true,
96
- });
97
- if (res.id && form.find("input[name=id")) {
98
- form.append(
99
- `<input type="hidden" class="form-control " name="id" value="${res.id}">`
100
- );
102
+ try {
103
+ showLoadSpinner();
104
+ const form = $(e).closest("form");
105
+ submitWithEmptyAction(form[0]);
106
+ const queryStr = new URLSearchParams(new FormData(form[0])).toString();
107
+ const res = await parent.router.resolve({
108
+ pathname: `post${action}`,
109
+ query: queryStr,
110
+ xhr: true,
111
+ });
112
+ if (res.id && form.find("input[name=id")) {
113
+ form.append(
114
+ `<input type="hidden" class="form-control " name="id" value="${res.id}">`
115
+ );
116
+ }
117
+ if (k) await k();
118
+ // TODO ch error (request.responseText?)
119
+ } finally {
120
+ removeLoadSpinner();
101
121
  }
102
- if (k) await k();
103
- // TODO ch error (request.responseText?)
104
122
  }
105
123
 
106
124
  async function loginRequest({ email, password, isSignup, isPublic }) {
@@ -131,78 +149,84 @@ async function loginRequest({ email, password, isSignup, isPublic }) {
131
149
  }
132
150
 
133
151
  async function login(e, entryPoint, isSignup) {
134
- const formData = new FormData(e);
135
- const loginResult = await loginRequest({
136
- email: formData.get("email"),
137
- password: formData.get("password"),
138
- isSignup,
139
- });
140
- if (typeof loginResult === "string") {
141
- // use it as a token
142
- const decodedJwt = parent.jwt_decode(loginResult);
143
- const state = parent.saltcorn.data.state.getState();
144
- const config = state.mobileConfig;
145
- config.role_id = decodedJwt.user.role_id ? decodedJwt.user.role_id : 100;
146
- config.user_name = decodedJwt.user.email;
147
- config.user_id = decodedJwt.user.id;
148
- config.language = decodedJwt.user.language;
149
- config.isPublicUser = false;
150
- config.isOfflineMode = false;
151
- await parent.insertUser({
152
- id: config.user_id,
153
- email: config.user_name,
154
- role_id: config.role_id,
155
- language: config.language,
152
+ try {
153
+ showLoadSpinner();
154
+ const formData = new FormData(e);
155
+ const loginResult = await loginRequest({
156
+ email: formData.get("email"),
157
+ password: formData.get("password"),
158
+ isSignup,
156
159
  });
157
- await parent.setJwt(loginResult);
158
- config.jwt = loginResult;
159
- await parent.i18next.changeLanguage(config.language);
160
- const alerts = [];
161
- if (config.allowOfflineMode) {
162
- const { offlineUser, upload_started_at, upload_ended_at } =
163
- (await parent.offlineHelper.getLastOfflineSession()) || {};
164
- if (offlineUser === config.user_name) {
165
- if (upload_started_at && !upload_ended_at) {
160
+ if (typeof loginResult === "string") {
161
+ // use it as a token
162
+ const decodedJwt = parent.jwt_decode(loginResult);
163
+ const state = parent.saltcorn.data.state.getState();
164
+ const config = state.mobileConfig;
165
+ config.role_id = decodedJwt.user.role_id ? decodedJwt.user.role_id : 100;
166
+ config.user_name = decodedJwt.user.email;
167
+ config.user_id = decodedJwt.user.id;
168
+ config.language = decodedJwt.user.language;
169
+ config.isPublicUser = false;
170
+ config.isOfflineMode = false;
171
+ await parent.insertUser({
172
+ id: config.user_id,
173
+ email: config.user_name,
174
+ role_id: config.role_id,
175
+ language: config.language,
176
+ });
177
+ await parent.setJwt(loginResult);
178
+ config.jwt = loginResult;
179
+ await parent.i18next.changeLanguage(config.language);
180
+ const alerts = [];
181
+ if (config.allowOfflineMode) {
182
+ const { offlineUser, upload_started_at, upload_ended_at } =
183
+ (await parent.offlineHelper.getLastOfflineSession()) || {};
184
+ if (offlineUser === config.user_name) {
185
+ if (upload_started_at && !upload_ended_at) {
186
+ alerts.push({
187
+ type: "warning",
188
+ msg: "Please check if your offline data is already online. An upload was started but did not finish.",
189
+ });
190
+ } else {
191
+ alerts.push({
192
+ type: "info",
193
+ msg: "You have offline data, to handle it open the Network menu.",
194
+ });
195
+ }
196
+ } else if (offlineUser) {
166
197
  alerts.push({
167
198
  type: "warning",
168
- msg: "Please check if your offline data is already online. An upload was started but did not finish.",
169
- });
170
- } else {
171
- alerts.push({
172
- type: "info",
173
- msg: "You have offline data, to handle it open the Network menu.",
199
+ msg: `'${offlineUser}' has not yet uploaded offline data.`,
174
200
  });
175
201
  }
176
- } else if (offlineUser) {
177
- alerts.push({
178
- type: "warning",
179
- msg: `'${offlineUser}' has not yet uploaded offline data.`,
180
- });
181
202
  }
203
+ alerts.push({
204
+ type: "success",
205
+ msg: parent.i18next.t("Welcome, %s!", {
206
+ postProcess: "sprintf",
207
+ sprintf: [config.user_name],
208
+ }),
209
+ });
210
+ parent.addRoute({ route: entryPoint, query: undefined });
211
+ const page = await parent.router.resolve({
212
+ pathname: entryPoint,
213
+ fullWrap: true,
214
+ alerts,
215
+ });
216
+ await parent.replaceIframe(page.content);
217
+ } else if (loginResult?.alerts) {
218
+ parent.showAlerts(loginResult?.alerts);
219
+ } else {
220
+ throw new Error("The login failed.");
182
221
  }
183
- alerts.push({
184
- type: "success",
185
- msg: parent.i18next.t("Welcome, %s!", {
186
- postProcess: "sprintf",
187
- sprintf: [config.user_name],
188
- }),
189
- });
190
- parent.addRoute({ route: entryPoint, query: undefined });
191
- const page = await parent.router.resolve({
192
- pathname: entryPoint,
193
- fullWrap: true,
194
- alerts,
195
- });
196
- await parent.replaceIframe(page.content);
197
- } else if (loginResult?.alerts) {
198
- parent.showAlerts(loginResult?.alerts);
199
- } else {
200
- throw new Error("The login failed.");
222
+ } finally {
223
+ removeLoadSpinner();
201
224
  }
202
225
  }
203
226
 
204
227
  async function publicLogin(entryPoint) {
205
228
  try {
229
+ showLoadSpinner();
206
230
  const loginResult = await loginRequest({ isPublic: true });
207
231
  if (typeof loginResult === "string") {
208
232
  const config = parent.saltcorn.data.state.getState().mobileConfig;
@@ -237,12 +261,15 @@ async function publicLogin(entryPoint) {
237
261
  msg: error.message ? error.message : "An error occured.",
238
262
  },
239
263
  ]);
264
+ } finally {
265
+ removeLoadSpinner();
240
266
  }
241
267
  }
242
268
 
243
269
  async function logout() {
244
270
  const config = parent.saltcorn.data.state.getState().mobileConfig;
245
271
  try {
272
+ showLoadSpinner();
246
273
  const page = await parent.router.resolve({
247
274
  pathname: "get/auth/logout",
248
275
  entryView: config.entry_point,
@@ -256,6 +283,8 @@ async function logout() {
256
283
  msg: error.message ? error.message : "An error occured.",
257
284
  },
258
285
  ]);
286
+ } finally {
287
+ removeLoadSpinner();
259
288
  }
260
289
  }
261
290
 
@@ -276,14 +305,19 @@ async function loginFormSubmit(e, entryView) {
276
305
  }
277
306
 
278
307
  async function local_post_btn(e) {
279
- const form = $(e).closest("form");
280
- const url = form.attr("action");
281
- const method = form.attr("method");
282
- const { path, query } = parent.splitPathQuery(url);
283
- await parent.handleRoute(
284
- `${method}${path}`,
285
- combineFormAndQuery(form, query)
286
- );
308
+ try {
309
+ showLoadSpinner();
310
+ const form = $(e).closest("form");
311
+ const url = form.attr("action");
312
+ const method = form.attr("method");
313
+ const { path, query } = parent.splitPathQuery(url);
314
+ await parent.handleRoute(
315
+ `${method}${path}`,
316
+ combineFormAndQuery(form, query)
317
+ );
318
+ } finally {
319
+ removeLoadSpinner();
320
+ }
287
321
  }
288
322
 
289
323
  /**
@@ -292,8 +326,13 @@ async function local_post_btn(e) {
292
326
  * @param {*} path
293
327
  */
294
328
  async function stateFormSubmit(e, path) {
295
- const formQuery = new URLSearchParams(new FormData(e)).toString();
296
- await parent.handleRoute(path, formQuery);
329
+ try {
330
+ showLoadSpinner();
331
+ const formQuery = new URLSearchParams(new FormData(e)).toString();
332
+ await parent.handleRoute(path, formQuery);
333
+ } finally {
334
+ removeLoadSpinner();
335
+ }
297
336
  }
298
337
 
299
338
  function removeQueryStringParameter(queryStr, key) {
@@ -337,33 +376,48 @@ function invalidate_pagings(currentQuery) {
337
376
  }
338
377
 
339
378
  async function set_state_fields(kvs, href) {
340
- let queryParams = [];
341
- let currentQuery = parent.currentQuery();
342
- if (Object.keys(kvs).some((k) => !is_paging_param(k))) {
343
- currentQuery = invalidate_pagings(currentQuery);
344
- }
345
- Object.entries(kvs).forEach((kv) => {
346
- if (kv[1].unset && kv[1].unset === true) {
347
- currentQuery = removeQueryStringParameter(currentQuery, kv[0]);
348
- } else {
349
- currentQuery = updateQueryStringParameter(currentQuery, kv[0], kv[1]);
379
+ try {
380
+ showLoadSpinner();
381
+ let queryParams = [];
382
+ let currentQuery = parent.currentQuery();
383
+ if (Object.keys(kvs).some((k) => !is_paging_param(k))) {
384
+ currentQuery = invalidate_pagings(currentQuery);
350
385
  }
351
- });
352
- for (const [k, v] of new URLSearchParams(currentQuery).entries()) {
353
- queryParams.push(`${k}=${v}`);
386
+ Object.entries(kvs).forEach((kv) => {
387
+ if (kv[1].unset && kv[1].unset === true) {
388
+ currentQuery = removeQueryStringParameter(currentQuery, kv[0]);
389
+ } else {
390
+ currentQuery = updateQueryStringParameter(currentQuery, kv[0], kv[1]);
391
+ }
392
+ });
393
+ for (const [k, v] of new URLSearchParams(currentQuery).entries()) {
394
+ queryParams.push(`${k}=${v}`);
395
+ }
396
+ await parent.handleRoute(href, queryParams.join("&"));
397
+ } finally {
398
+ removeLoadSpinner();
354
399
  }
355
- await parent.handleRoute(href, queryParams.join("&"));
356
400
  }
357
401
 
358
402
  async function set_state_field(key, value) {
359
- const query = updateQueryStringParameter(parent.currentQuery(), key, value);
360
- await parent.handleRoute(parent.currentLocation(), query);
403
+ try {
404
+ showLoadSpinner();
405
+ const query = updateQueryStringParameter(parent.currentQuery(), key, value);
406
+ await parent.handleRoute(parent.currentLocation(), query);
407
+ } finally {
408
+ removeLoadSpinner();
409
+ }
361
410
  }
362
411
 
363
412
  async function unset_state_field(key) {
364
- const href = parent.currentLocation();
365
- const query = removeQueryStringParameter(parent.currentLocation(), key);
366
- await parent.handleRoute(href, query);
413
+ try {
414
+ showLoadSpinner();
415
+ const href = parent.currentLocation();
416
+ const query = removeQueryStringParameter(parent.currentLocation(), key);
417
+ await parent.handleRoute(href, query);
418
+ } finally {
419
+ removeLoadSpinner();
420
+ }
367
421
  }
368
422
 
369
423
  async function sortby(k, desc, viewIdentifier) {
@@ -456,6 +510,7 @@ function closeModal() {
456
510
 
457
511
  async function local_post(url, args) {
458
512
  try {
513
+ showLoadSpinner();
459
514
  const result = await parent.router.resolve({
460
515
  pathname: `post${url}`,
461
516
  data: args,
@@ -464,11 +519,14 @@ async function local_post(url, args) {
464
519
  else common_done(result);
465
520
  } catch (error) {
466
521
  parent.errorAlert(error);
522
+ } finally {
523
+ removeLoadSpinner();
467
524
  }
468
525
  }
469
526
 
470
527
  async function local_post_json(url) {
471
528
  try {
529
+ showLoadSpinner();
472
530
  const result = await parent.router.resolve({
473
531
  pathname: `post${url}`,
474
532
  });
@@ -477,6 +535,8 @@ async function local_post_json(url) {
477
535
  else common_done(result);
478
536
  } catch (error) {
479
537
  parent.errorAlert(error);
538
+ } finally {
539
+ removeLoadSpinner();
480
540
  }
481
541
  }
482
542
 
@@ -545,26 +605,46 @@ function openFile(fileId) {
545
605
  }
546
606
 
547
607
  async function select_id(id) {
548
- const newQuery = updateQueryStringParameter(parent.currentQuery(), "id", id);
549
- await parent.handleRoute(parent.currentLocation(), newQuery);
608
+ try {
609
+ showLoadSpinner();
610
+ const newQuery = updateQueryStringParameter(
611
+ parent.currentQuery(),
612
+ "id",
613
+ id
614
+ );
615
+ await parent.handleRoute(parent.currentLocation(), newQuery);
616
+ } finally {
617
+ removeLoadSpinner();
618
+ }
550
619
  }
551
620
 
552
621
  async function check_state_field(that) {
553
- const name = that.name;
554
- const newQuery = that.checked
555
- ? updateQueryStringParameter(parent.currentQuery(), name, that.value)
556
- : removeQueryStringParameter(name);
557
- await parent.handleRoute(parent.currentLocation(), newQuery);
622
+ try {
623
+ showLoadSpinner();
624
+ const name = that.name;
625
+ const newQuery = that.checked
626
+ ? updateQueryStringParameter(parent.currentQuery(), name, that.value)
627
+ : removeQueryStringParameter(name);
628
+ await parent.handleRoute(parent.currentLocation(), newQuery);
629
+ } finally {
630
+ removeLoadSpinner();
631
+ }
558
632
  }
559
633
 
560
634
  async function clear_state() {
561
- await parent.handleRoute(parent.currentLocation(), undefined);
635
+ try {
636
+ showLoadSpinner();
637
+ await parent.handleRoute(parent.currentLocation(), undefined);
638
+ } finally {
639
+ removeLoadSpinner();
640
+ }
562
641
  }
563
642
 
564
643
  async function view_post(viewname, route, data, onDone) {
565
644
  const mobileConfig = parent.saltcorn.data.state.getState().mobileConfig;
566
645
  const view = parent.saltcorn.data.models.View.findOne({ name: viewname });
567
646
  try {
647
+ showLoadSpinner();
568
648
  let respData = undefined;
569
649
  if (
570
650
  mobileConfig.isOfflineMode ||
@@ -589,6 +669,8 @@ async function view_post(viewname, route, data, onDone) {
589
669
  common_done(respData);
590
670
  } catch (error) {
591
671
  parent.errorAlert(error);
672
+ } finally {
673
+ removeLoadSpinner();
592
674
  }
593
675
  }
594
676