@oat-sa/tao-core-ui 3.23.0 → 3.24.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.
Files changed (31) hide show
  1. package/dist/figure/FigureStateActive.js +64 -32
  2. package/dist/image/ImgStateActive/initHelper.js +64 -32
  3. package/dist/image/ImgStateActive.js +64 -32
  4. package/dist/previewer.js +1 -1
  5. package/dist/resourcemgr/assetSearch.js +793 -0
  6. package/dist/resourcemgr/assetSearchContract.js +477 -0
  7. package/dist/resourcemgr/css/resourcemgr.css +1230 -97
  8. package/dist/resourcemgr/css/resourcemgr.css.map +1 -1
  9. package/dist/resourcemgr/fileBrowser.js +815 -83
  10. package/dist/resourcemgr/filePreview.js +82 -7
  11. package/dist/resourcemgr/fileSelector.js +377 -92
  12. package/dist/resourcemgr.js +314 -57
  13. package/dist/searchModal/advancedSearch.js +233 -31
  14. package/package.json +15 -6
  15. package/src/image/ImgStateActive/initHelper.js +69 -31
  16. package/src/previewer.js +1 -1
  17. package/src/resourcemgr/assetSearch.js +847 -0
  18. package/src/resourcemgr/assetSearchContract.js +489 -0
  19. package/src/resourcemgr/css/resourcemgr.css +1230 -97
  20. package/src/resourcemgr/css/resourcemgr.css.map +1 -1
  21. package/src/resourcemgr/fileBrowser.js +872 -92
  22. package/src/resourcemgr/filePreview.js +89 -8
  23. package/src/resourcemgr/fileSelector.js +356 -46
  24. package/src/resourcemgr/scss/resourcemgr.scss +1450 -89
  25. package/src/resourcemgr/tpl/advanced-search.tpl +10 -0
  26. package/src/resourcemgr/tpl/fileSelect.tpl +25 -28
  27. package/src/resourcemgr/tpl/folder.tpl +4 -1
  28. package/src/resourcemgr/tpl/layout.tpl +125 -54
  29. package/src/resourcemgr/tpl/rootFolder.tpl +3 -1
  30. package/src/resourcemgr.js +294 -47
  31. package/src/searchModal/advancedSearch.js +242 -31
@@ -11,65 +11,194 @@
11
11
  *
12
12
  * You should have received a copy of the GNU General Public License
13
13
  * along with this program; if not, write to the Free Software
14
- * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
14
+ * Foundation, Inc., 31 Milk St # 960789 Boston, MA 02196 USA.
15
15
  *
16
- * Copyright (c) 2018-2020 (original work) Open Assessment Technologies SA;
16
+ * Copyright (c) 2018-2026 (original work) Open Assessment Technologies SA;
17
17
  */
18
18
  import $ from 'jquery';
19
19
  import _ from 'lodash';
20
- import request from 'core/request';
21
20
  import paginationComponent from 'ui/pagination';
22
21
  import rootFolderTpl from 'ui/resourcemgr/tpl/rootFolder';
23
22
  import folderTpl from 'ui/resourcemgr/tpl/folder';
23
+ import loggerFactory from 'core/logger';
24
24
  import updatePermissions from './util/updatePermissions';
25
+ import { DEFAULT_SORT, sortAssetItems } from 'ui/resourcemgr/assetSearchContract';
25
26
 
26
- const ns = 'resourcemgr';
27
+ const NS = 'resourcemgr';
28
+ const LOGGER = loggerFactory(`ui/${NS}`);
29
+ const DEFAULT_AJAX_TIMEOUT_MS = 30000;
30
+ const TREE_LOADING_REF_KEY = `${NS}TreeLoadingRefCount`;
31
+
32
+ /**
33
+ * @param {Object} data
34
+ * @returns {Array}
35
+ */
36
+ function folderChildrenOnly(data) {
37
+ return _.filter(data && data.children, function (child) {
38
+ return child && child.path && !child.uri;
39
+ });
40
+ }
27
41
 
28
42
  export default function (options) {
43
+ if (!options.browseUrl && options.url) {
44
+ options.browseUrl = options.url;
45
+ }
46
+
29
47
  const root = options.root || 'local';
30
48
  const rootPath = options.path || '/';
49
+ const initialPath = options.initialPath || rootPath;
31
50
  const $container = options.$target;
32
51
  const $fileBrowser = $('.file-browser .file-browser-wrapper', $container);
33
52
  const $divContainer = $(`.${root}`, $fileBrowser);
34
53
  const $folderContainer = $('.folders', $divContainer);
54
+ const $paginationContainer = $('.pagination-bottom', $container);
35
55
  const fileTree = {};
36
56
  // for pagination
37
57
  let selectedClass = {
38
58
  path: rootPath,
39
- childrenLimit: 10,
59
+ childrenLimit: 11,
40
60
  total: 0,
41
61
  page: 1
42
62
  };
63
+ let searchMode = false;
64
+ let sort = Object.assign({}, DEFAULT_SORT);
65
+ const splitBrowse = options.splitBrowse !== false;
66
+ const ajaxTimeoutMs = Number.isFinite(Number(options.ajaxTimeoutMs)) && Number(options.ajaxTimeoutMs) > 0
67
+ ? Number(options.ajaxTimeoutMs)
68
+ : DEFAULT_AJAX_TIMEOUT_MS;
69
+ const $treeLoadingOverlay = $('.file-browser-tree-loading', $fileBrowser);
70
+ const $treeLoadingStatus = $('.file-browser-tree-loading-status', $treeLoadingOverlay);
71
+
72
+ /**
73
+ * Ref-count in-flight browse requests and toggle the tree loading overlay.
74
+ * @param {Boolean} increment - true when a load starts, false when it finishes
75
+ */
76
+ function setTreeLoading(increment) {
77
+ let treeLoadingRefCount = Number($fileBrowser.data(TREE_LOADING_REF_KEY)) || 0;
78
+ treeLoadingRefCount += increment ? 1 : -1;
79
+ if (treeLoadingRefCount < 0) {
80
+ treeLoadingRefCount = 0;
81
+ }
82
+ $fileBrowser.data(TREE_LOADING_REF_KEY, treeLoadingRefCount);
83
+ const loading = treeLoadingRefCount > 0;
84
+ $fileBrowser.toggleClass('is-tree-loading', loading);
85
+ $fileBrowser.attr('aria-busy', loading ? 'true' : 'false');
86
+ $treeLoadingOverlay.toggleClass('hidden', !loading).attr('aria-hidden', loading ? 'false' : 'true');
87
+ $treeLoadingStatus.attr('aria-busy', loading ? 'true' : 'false');
88
+ }
89
+
90
+ /**
91
+ * Show or hide the asset table loading overlay (browse mode only).
92
+ * @param {Boolean} loading
93
+ */
94
+ function setFilesLoading(loading) {
95
+ if (searchMode) {
96
+ return;
97
+ }
98
+ $container.trigger(`filesloading.${NS}`, [!!loading]);
99
+ }
100
+
101
+ $container.on(`searchmode.${NS}`, function (e, enabled) {
102
+ searchMode = !!enabled;
103
+ if (searchMode) {
104
+ $container.trigger(`filesloading.${NS}`, [false]);
105
+ }
106
+ });
107
+
108
+ $container.on(`sortchange.${NS}`, function (e, nextSort) {
109
+ sort = Object.assign({}, DEFAULT_SORT, nextSort || {});
110
+ selectedClass.page = 1;
111
+ invalidateFolderFiles(selectedClass.path);
112
+ if (searchMode || !isActiveBrowser()) {
113
+ return;
114
+ }
115
+ reloadSortedFolder();
116
+ });
117
+
118
+ $container.on(`searchclear.${NS}`, function (e, path) {
119
+ const targetPath = path || selectedClass.path;
120
+ selectedClass.page = 1;
121
+ invalidateFolderFiles(targetPath);
122
+ if (!isActiveBrowser()) {
123
+ return;
124
+ }
125
+ const subTree = getByExactPath(fileTree, targetPath) || getByPath(fileTree, targetPath) || fileTree;
126
+ if (!searchMode) {
127
+ setFilesLoading(true);
128
+ }
129
+ getFolderContent(subTree, targetPath, function (content) {
130
+ if (content) {
131
+ selectFolder(content, targetPath);
132
+ } else {
133
+ setFilesLoading(false);
134
+ }
135
+ });
136
+ });
137
+
138
+ // Reopen with resolved parent (AC3 edit/change): leave search, open folder again.
139
+ $container.on(`applycontext.${NS}`, function (e, ctx) {
140
+ const path = (ctx && ctx.path) || rootPath;
141
+ const pathIsTaomedia = path && String(path).indexOf('taomedia://') === 0;
142
+ if (pathIsTaomedia && root === 'local') {
143
+ return;
144
+ }
145
+ if (!pathIsTaomedia && root !== 'local') {
146
+ return;
147
+ }
148
+ $container.data('activeFileBrowserRoot', root);
149
+ if (searchMode) {
150
+ $container.trigger(`requestexitsearch.${NS}`);
151
+ }
152
+ selectedClass.page = 1;
153
+ openInitialPath(path);
154
+ });
43
155
 
44
156
  //load the content of the ROOT
157
+ const showInitialListLoading = !searchMode && root === 'local';
158
+ if (showInitialListLoading) {
159
+ setFilesLoading(true);
160
+ }
45
161
  getFolderContent(fileTree, rootPath, function (content) {
162
+ if (!content) {
163
+ if (showInitialListLoading) {
164
+ setFilesLoading(false);
165
+ }
166
+ return;
167
+ }
46
168
  indexTree(content);
47
169
 
48
170
  //create the tree node for the ROOT folder by default once the initial content loaded
49
- $folderContainer.append(rootFolderTpl(content));
171
+ $folderContainer.append(
172
+ rootFolderTpl(
173
+ Object.assign({}, content, {
174
+ showToggle: hasNestedFolderChildren(content) !== false
175
+ })
176
+ )
177
+ );
50
178
 
51
179
  const $rootNode = $('.root-folder', $folderContainer);
52
180
  //create an inner list and append found elements
53
181
  const $innerList = $('.root ul', $folderContainer);
54
- if (content.children) {
182
+ if (hasNestedFolderChildren(content)) {
55
183
  $rootNode.addClass('opened');
184
+ } else if (hasNestedFolderChildren(content) === false) {
185
+ setFolderToggleState($rootNode, false);
56
186
  }
57
187
  updateFolders(content, $innerList);
58
188
 
59
- if (content.permissions.read && !options.hasAlreadySelected) {
60
- $('.file-browser').find('li.active').removeClass('active');
61
- updateSelectedClass(content.path, content.total, content.childrenLimit);
62
- $container.trigger('folderselect.'.concat(ns), [
63
- content.label,
64
- getPage(content.children),
65
- content.path,
66
- content
67
- ]);
68
- renderPagination();
189
+ if (content.permissions && content.permissions.read && !options.hasAlreadySelected) {
190
+ if (initialPath && initialPath !== rootPath) {
191
+ openInitialPath(initialPath);
192
+ } else {
193
+ selectFolder(content, content.path);
194
+ syncTreeActiveFolder(content.path || rootPath);
195
+ }
69
196
 
70
197
  if (root !== 'local') {
71
198
  options.hasAlreadySelected = true;
72
199
  }
200
+ } else if (showInitialListLoading) {
201
+ setFilesLoading(false);
73
202
  }
74
203
  });
75
204
 
@@ -77,9 +206,17 @@ export default function (options) {
77
206
  $divContainer.off('click', '.folders a').on('click', '.folders a', function (e) {
78
207
  e.preventDefault();
79
208
  const $selected = $(this);
80
- const $folders = $('.folders li', $fileBrowser);
81
209
  const fullPath = $selected.data('path');
82
- const subTree = getByPath(fileTree, fullPath);
210
+ const subTree = getByExactPath(fileTree, fullPath);
211
+ const openingFolder = !searchMode && fullPath !== selectedClass.path;
212
+
213
+ if (openingFolder) {
214
+ selectedClass.page = 1;
215
+ }
216
+ if (!searchMode) {
217
+ setFilesLoading(true);
218
+ }
219
+ invalidateFolderFiles(fullPath);
83
220
 
84
221
  //get the folder content
85
222
  getFolderContent(subTree, fullPath, function (content) {
@@ -88,10 +225,12 @@ export default function (options) {
88
225
  if (content) {
89
226
  //either create the inner list of the content is new or just show it
90
227
  let $innerList = $selected.siblings('ul');
91
- if (!$innerList.length && content.children && _.find(content.children, 'path') && !content.empty) {
228
+ const nested = hasNestedFolderChildren(content);
229
+ if (!$innerList.length && nested) {
92
230
  $innerList = $('<ul></ul>').insertAfter($selected);
93
231
  updateFolders(content, $innerList);
94
232
  $selected.addClass('opened');
233
+ setFolderToggleState($selected, true);
95
234
  } else if ($innerList.length) {
96
235
  if ($innerList.css('display') === 'none') {
97
236
  $innerList.show();
@@ -100,68 +239,222 @@ export default function (options) {
100
239
  $innerList.hide();
101
240
  $selected.removeClass('opened');
102
241
  }
242
+ } else if (nested === false) {
243
+ // Leaf folder: keep alignment spacer, hide expand chevron.
244
+ setFolderToggleState($selected, false);
103
245
  }
104
246
 
105
- //toggle active element
106
- $folders.removeClass('active');
107
- $selected.parent('li').addClass('active');
247
+ syncTreeActiveFolder(fullPath, $selected);
108
248
 
109
249
  //internal event to set the file-selector content
110
- updateSelectedClass(fullPath, content.total, content.childrenLimit);
111
- $container.trigger(`folderselect.${ns}`, [
112
- content.label,
113
- getPage(content.children),
114
- content.path,
115
- content
116
- ]);
117
- renderPagination();
250
+ selectFolder(content, fullPath);
251
+ } else if (!searchMode) {
252
+ setFilesLoading(false);
118
253
  }
119
254
  });
120
255
  });
121
256
 
122
- $container.on(`filenew.${ns}`, function (e, file, path) {
123
- const subTree = getByPath(fileTree, path);
124
- if (subTree) {
125
- if (!subTree.children) {
126
- subTree.children = [];
127
- }
128
- if (root !== 'local' || !_.find(subTree.children, { name: file.name })) {
129
- updatePermissions(file);
130
- const childrenFilesOnly = _.filter(subTree.children, function (child) {
131
- // Only file object has link property
132
- return Object.prototype.hasOwnProperty.call(child, 'link');
133
- });
257
+ $container.on(`filenew.${NS}`, function (e, file, path) {
258
+ const targetPath = path || selectedClass.path;
259
+ if (!targetPath || searchMode) {
260
+ return;
261
+ }
134
262
 
135
- if (childrenFilesOnly.length === subTree.total) {
136
- // all children loaded new file can be pushed to the end of tree
137
- // if not all, new file will be loaded with next page
138
- subTree.children.push(file);
139
- }
140
- subTree.total = Number.isFinite(Number(subTree.total)) ? Number(subTree.total) + 1 : 1;
141
- if (selectedClass.path === path) {
142
- selectedClass.total = subTree.total;
143
- }
144
- $container.trigger(`folderselect.${ns}`, [subTree.label, getPage(subTree.children), path, subTree]);
145
- renderPagination();
146
- }
263
+ const activeRoot = $container.data('activeFileBrowserRoot');
264
+ if (activeRoot && activeRoot !== root) {
265
+ return;
266
+ }
267
+
268
+ const subTree = getByExactPath(fileTree, targetPath) || getByPath(fileTree, targetPath);
269
+ if (subTree && root === 'local' && file && file.name && _.find(subTree.children, { name: file.name })) {
270
+ return;
147
271
  }
272
+
273
+ if (targetPath !== selectedClass.path) {
274
+ selectedClass.path = targetPath;
275
+ }
276
+ selectedClass.page = 1;
277
+
278
+ invalidateFolderFiles(targetPath);
279
+ const pendingUpload =
280
+ file && !file.error && (file.uri || file.name) ? file : null;
281
+ reloadSortedFolder(pendingUpload ? [pendingUpload] : []);
148
282
  });
149
283
 
150
- $container.on(`filedelete.${ns}`, function (e, path) {
151
- if (removeFromPath(fileTree, path)) {
152
- selectedClass.total--;
153
- loadPage();
284
+ $container.on(`filedelete.${NS}`, function (e, path) {
285
+ if (searchMode) {
286
+ return;
154
287
  }
288
+
289
+ const activeRoot = $container.data('activeFileBrowserRoot');
290
+ if (activeRoot && activeRoot !== root) {
291
+ return;
292
+ }
293
+
294
+ if (path) {
295
+ removeFromPath(fileTree, path);
296
+ }
297
+
298
+ invalidateFolderFiles(selectedClass.path);
299
+ reloadSortedFolder();
155
300
  });
301
+
302
+ /**
303
+ * Open and select an initial folder path after the root tree is available.
304
+ * @param {String} path
305
+ */
306
+ function findFolderLink(path) {
307
+ return $folderContainer.find('a').filter(function () {
308
+ return $(this).data('path') === path;
309
+ });
310
+ }
311
+
312
+ /**
313
+ * Render directory children under an expanded folder anchor (replace / reopen).
314
+ * @param {String} parentPath
315
+ * @param {Object} parentContent
316
+ */
317
+ function mountFolderBranch(parentPath, parentContent) {
318
+ const $parentLink = findFolderLink(parentPath);
319
+ if (!$parentLink.length || !parentContent) {
320
+ return;
321
+ }
322
+ let $innerList = $parentLink.siblings('ul');
323
+ if (!$innerList.length) {
324
+ $innerList = $('<ul></ul>').insertAfter($parentLink);
325
+ }
326
+ updateFolders(parentContent, $innerList);
327
+ $parentLink.addClass('opened');
328
+ if ($innerList.css('display') === 'none') {
329
+ $innerList.show();
330
+ }
331
+ setFolderToggleState($parentLink, hasNestedFolderChildren(parentContent) !== false);
332
+ }
333
+
334
+ function markFolderActive(path) {
335
+ const $targetLink = findFolderLink(path);
336
+ if ($targetLink.length) {
337
+ $targetLink.parents('li').each(function () {
338
+ const $li = $(this);
339
+ const $anchor = $li.children('a');
340
+ const $list = $li.children('ul');
341
+ $anchor.addClass('opened');
342
+ if ($list.length) {
343
+ $list.show();
344
+ }
345
+ });
346
+ syncTreeActiveFolder(path, $targetLink);
347
+ } else if (path === rootPath) {
348
+ syncTreeActiveFolder(rootPath);
349
+ }
350
+ }
351
+
352
+ function openInitialPath(path) {
353
+ getFolderContent(fileTree, path, function (content) {
354
+ indexTree(fileTree);
355
+ if (!content) {
356
+ const rootContent = getByPath(fileTree, rootPath) || fileTree;
357
+ const fallbackPath = rootContent.path || rootPath;
358
+ selectFolder(rootContent, fallbackPath);
359
+ syncTreeActiveFolder(fallbackPath);
360
+ return;
361
+ }
362
+
363
+ function finishOpen() {
364
+ markFolderActive(path);
365
+ selectFolder(content, path);
366
+ }
367
+
368
+ if (findFolderLink(path).length) {
369
+ finishOpen();
370
+ return;
371
+ }
372
+
373
+ const parentFolderPath = content.parentFolderPath;
374
+ if (parentFolderPath && parentFolderPath !== path) {
375
+ getFolderContent(fileTree, parentFolderPath, function (parentContent) {
376
+ mountFolderBranch(parentFolderPath, parentContent);
377
+ finishOpen();
378
+ });
379
+ return;
380
+ }
381
+
382
+ finishOpen();
383
+ });
384
+ }
385
+
386
+ /**
387
+ * Mark the folder row that matches path as active (open folder icon for leaves).
388
+ * @param {String} path
389
+ * @param {jQuery} [$link]
390
+ */
391
+ function syncTreeActiveFolder(path, $link) {
392
+ $('.folders li', $fileBrowser).removeClass('active');
393
+ const $target =
394
+ $link && $link.length
395
+ ? $link
396
+ : $folderContainer.find('a').filter(function () {
397
+ return $(this).data('path') === path;
398
+ });
399
+ if ($target.length) {
400
+ $target.parent('li').addClass('active');
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Whether this media source currently owns the file table.
406
+ * @returns {Boolean}
407
+ */
408
+ function isActiveBrowser() {
409
+ return $container.data('activeFileBrowserRoot') === root;
410
+ }
411
+
412
+ /**
413
+ * Select a folder and publish its page of files to the selector.
414
+ * @param {Object} content
415
+ * @param {String} path
416
+ */
417
+ function selectFolder(content, path) {
418
+ if (!content) {
419
+ return;
420
+ }
421
+ $container.data('activeFileBrowserRoot', root);
422
+ if (searchMode) {
423
+ $container.trigger(`folderpath.${NS}`, [path, content.label]);
424
+ $container.trigger(`folderselect.${NS}`, [
425
+ content.label,
426
+ getFilesForDisplay(content),
427
+ path,
428
+ content
429
+ ]);
430
+ return;
431
+ }
432
+ updateSelectedClass(path, content.total, content.childrenLimit);
433
+ $container.trigger(`folderpath.${NS}`, [path, content.label]);
434
+ $container.trigger(`folderselect.${NS}`, [
435
+ content.label,
436
+ getFilesForDisplay(content),
437
+ path,
438
+ content
439
+ ]);
440
+ renderPagination();
441
+ if (!searchMode) {
442
+ setFilesLoading(false);
443
+ }
444
+ }
445
+
156
446
  /**
157
447
  * Get files for page
158
448
  * @param {Array} children
159
449
  * @returns {Array} files for this page
160
450
  */
161
451
  function getPage(children) {
162
- const files = _.filter(children, function (item) {
163
- return !!item.uri;
164
- });
452
+ const files = sortAssetItems(
453
+ _.filter(children, function (item) {
454
+ return !!item.uri;
455
+ }),
456
+ sort
457
+ );
165
458
  if (selectedClass.childrenLimit) {
166
459
  return files.slice(
167
460
  (selectedClass.page - 1) * selectedClass.childrenLimit,
@@ -170,45 +463,295 @@ export default function (options) {
170
463
  }
171
464
  return files;
172
465
  }
466
+
467
+ /**
468
+ * Files for the table (split list payload or legacy children slice).
469
+ * @param {Object} content
470
+ * @returns {Array}
471
+ */
472
+ function getFilesForDisplay(content) {
473
+ if (!content) {
474
+ return [];
475
+ }
476
+ if (splitBrowse) {
477
+ return content.listItems || [];
478
+ }
479
+ return getPage(content.children || []);
480
+ }
481
+
482
+ function listCacheKey() {
483
+ return `${sort.field}:${sort.direction}:${selectedClass.page}`;
484
+ }
485
+
486
+ /**
487
+ * Snapshot list request identity (folder + page/sort) when a browse starts.
488
+ * @returns {{path: String, cacheKey: String}}
489
+ */
490
+ function captureListRequestContext() {
491
+ return {
492
+ path: selectedClass.path,
493
+ cacheKey: listCacheKey()
494
+ };
495
+ }
496
+
497
+ /**
498
+ * Whether navigation changed before an in-flight list response should apply.
499
+ * @param {{path: String, cacheKey: String}} captured
500
+ * @returns {Boolean}
501
+ */
502
+ function isListRequestStale(captured) {
503
+ return (
504
+ !captured ||
505
+ captured.path !== selectedClass.path ||
506
+ captured.cacheKey !== listCacheKey()
507
+ );
508
+ }
509
+
510
+ /**
511
+ * @param {Object} response
512
+ * @returns {Object}
513
+ */
514
+ function unwrapBrowseResponse(response) {
515
+ if (response && response.success === false) {
516
+ return Promise.reject(response);
517
+ }
518
+ let data = response && response.data ? response.data : response;
519
+ data = updatePermissions(data);
520
+ if (data.children && data.children.length > 0) {
521
+ data.children = data.children.map(function (child) {
522
+ return updatePermissions(child);
523
+ });
524
+ }
525
+ if (data.items && data.items.length > 0) {
526
+ data.items = data.items.map(function (item) {
527
+ return updatePermissions(item);
528
+ });
529
+ }
530
+ return data;
531
+ }
532
+
533
+ /**
534
+ * @param {String} path
535
+ * @param {Object} extraData
536
+ * @returns {Promise}
537
+ */
538
+ function browseRequest(path, extraData) {
539
+ const parameters = {};
540
+ parameters[options.pathParam || 'path'] = path;
541
+ return Promise.resolve(
542
+ $.ajax({
543
+ url: options.browseUrl,
544
+ method: 'GET',
545
+ dataType: 'json',
546
+ timeout: ajaxTimeoutMs,
547
+ data: _.merge(parameters, options.params, extraData)
548
+ })
549
+ ).then(unwrapBrowseResponse);
550
+ }
551
+
552
+ /**
553
+ * @param {Object} tree
554
+ * @param {String} path
555
+ * @param {Object} data
556
+ */
557
+ function applyTreePayload(tree, path, data) {
558
+ const folderData = Object.assign({}, data, { children: folderChildrenOnly(data) });
559
+ if (!tree.path) {
560
+ _.merge(tree, folderData);
561
+ } else if (folderData.children) {
562
+ setToPath(tree, path, folderData);
563
+ if (!_.find(folderData.children, 'path')) {
564
+ markFolderEmptyAtPath(tree, path);
565
+ }
566
+ } else {
567
+ setToPath(tree, path, folderData);
568
+ markFolderEmptyAtPath(tree, path);
569
+ }
570
+ }
571
+
572
+ /**
573
+ * @param {Object} tree
574
+ * @param {String} path
575
+ * @param {Object} listData
576
+ * @param {String} cacheKey
577
+ */
578
+ function applyListPayload(tree, path, listData, cacheKey) {
579
+ const node = getByExactPath(tree, path) || getByPath(tree, path);
580
+ if (!node || !listData) {
581
+ return;
582
+ }
583
+ node.listItems = listData.items || [];
584
+ node.total = listData.total;
585
+ node.childrenLimit = listData.pageSize || listData.childrenLimit || selectedClass.childrenLimit;
586
+ node.listPage = listData.page;
587
+ node.totalIsApproximate = listData.totalIsApproximate;
588
+ node.listCacheKey = cacheKey;
589
+ }
590
+
591
+ /**
592
+ * Ensure upload responses appear in the table before Elasticsearch catches up.
593
+ * @param {String} path
594
+ * @param {Array<Object>} uploads
595
+ */
596
+ function mergePendingUploads(path, uploads) {
597
+ if (!uploads || !uploads.length) {
598
+ return;
599
+ }
600
+ const node = getByExactPath(fileTree, path) || getByPath(fileTree, path);
601
+ if (!node) {
602
+ return;
603
+ }
604
+ if (!Array.isArray(node.listItems)) {
605
+ node.listItems = [];
606
+ }
607
+ let added = 0;
608
+ uploads.forEach(function (uploaded) {
609
+ if (!uploaded || uploaded.error) {
610
+ return;
611
+ }
612
+ const normalized = updatePermissions(uploaded);
613
+ if (!normalized.uri && !normalized.name) {
614
+ return;
615
+ }
616
+ const exists = _.some(node.listItems, function (item) {
617
+ return (
618
+ (normalized.uri && item.uri === normalized.uri) ||
619
+ (normalized.name && item.name === normalized.name)
620
+ );
621
+ });
622
+ if (exists) {
623
+ return;
624
+ }
625
+ node.listItems.push(normalized);
626
+ added++;
627
+ });
628
+ if (!added) {
629
+ return;
630
+ }
631
+ if (splitBrowse) {
632
+ node.listItems = sortAssetItems(node.listItems, sort);
633
+ }
634
+ const total = Number(node.total);
635
+ if (Number.isFinite(total) && total >= 0) {
636
+ node.total = total + added;
637
+ }
638
+ }
639
+
173
640
  /**
174
641
  * Get the content of a folder, either in the model or load it
175
642
  * @param {Object} tree - the tree model
176
643
  * @param {String} path - the folder path (relative to the root)
177
644
  * @param {Function} cb - called back with the content in 1st parameter
178
645
  */
646
+ function markFolderEmptyAtPath(tree, path) {
647
+ const node = getByExactPath(tree, path);
648
+ if (node) {
649
+ node.empty = true;
650
+ }
651
+ }
652
+
179
653
  function getFolderContent(tree, path, cb) {
654
+ if (splitBrowse) {
655
+ const existing = getByExactPath(tree, path) || getByPath(tree, path);
656
+ const needTree = !existing || (!existing.children && !existing.empty);
657
+ const cacheKey = listCacheKey();
658
+ const needList = !existing || existing.listCacheKey !== cacheKey;
659
+
660
+ const treePromise = needTree
661
+ ? (setTreeLoading(true),
662
+ browseRequest(path, {
663
+ part: 'tree',
664
+ depth: 1,
665
+ sortBy: sort.field,
666
+ sortDir: sort.direction
667
+ }).then(
668
+ function (data) {
669
+ setTreeLoading(false);
670
+ return data;
671
+ },
672
+ function (error) {
673
+ setTreeLoading(false);
674
+ return Promise.reject(error);
675
+ }
676
+ ))
677
+ : Promise.resolve(null);
678
+
679
+ const listPromise = needList
680
+ ? browseRequest(path, {
681
+ part: 'list',
682
+ page: selectedClass.page,
683
+ pageSize: selectedClass.childrenLimit || 11,
684
+ sortBy: sort.field,
685
+ sortDir: sort.direction
686
+ })
687
+ : Promise.resolve(null);
688
+
689
+ Promise.all([treePromise, listPromise])
690
+ .then(function ([treeData, listData]) {
691
+ if (treeData) {
692
+ applyTreePayload(tree, path, treeData);
693
+ }
694
+ if (listData) {
695
+ applyListPayload(tree, path, listData, cacheKey);
696
+ }
697
+ cb(getByExactPath(tree, path) || getByPath(tree, path));
698
+ })
699
+ .catch(function () {
700
+ cb(null);
701
+ });
702
+ return;
703
+ }
704
+
180
705
  let content = getByPath(tree, path);
181
706
  if (!content || (!content.children && !content.empty)) {
182
707
  loadContent(path).then(function (data) {
183
708
  if (!tree.path) {
184
709
  tree = _.merge(tree, data);
185
710
  } else if (data.children) {
711
+ setToPath(tree, path, data);
186
712
  if (!_.find(data.children, 'path')) {
187
713
  // no subfolders inside folder
188
- tree.empty = true;
714
+ markFolderEmptyAtPath(tree, path);
189
715
  }
190
- setToPath(tree, path, data);
191
716
  } else {
192
- tree.empty = true;
717
+ setToPath(tree, path, data);
718
+ markFolderEmptyAtPath(tree, path);
193
719
  }
194
720
  cb(data);
721
+ }).catch(function () {
722
+ cb(null);
195
723
  });
196
724
  } else if (content.children) {
197
725
  const files = _.filter(content.children, function (item) {
198
726
  return !!item.uri;
199
727
  });
200
- // if files less then total and need toload this page
201
- if (files.length < selectedClass.total && files.length < selectedClass.page * selectedClass.childrenLimit) {
728
+ // Use folder total (not selectedClass): openInitialPath runs before selectFolder.
729
+ const expectedTotal = Number(content.total);
730
+ const pageSize = Number(content.childrenLimit) || selectedClass.childrenLimit || 11;
731
+ const page = selectedClass.page || 1;
732
+ // Missing/NaN total → unknown size; refetch so invalidated cache is not treated as complete.
733
+ if (
734
+ !Number.isFinite(expectedTotal) ||
735
+ (files.length < expectedTotal && files.length < page * pageSize)
736
+ ) {
202
737
  loadContent(path).then(function (data) {
203
738
  const loadedFiles = _.filter(data.children, function (item) {
204
739
  return !!item.uri;
205
740
  });
741
+ const node = getByExactPath(tree, path);
742
+ if (node && !Number.isFinite(expectedTotal)) {
743
+ node.children = _.filter(node.children, function (item) {
744
+ return !item.uri;
745
+ });
746
+ }
206
747
  setToPath(tree, path, {
207
748
  children: loadedFiles,
208
749
  total: data.total,
209
750
  childrenLimit: data.childrenLimit
210
751
  });
211
- content = getByPath(tree, path);
752
+ content = getByExactPath(tree, path) || getByPath(tree, path);
753
+ cb(content);
754
+ }).catch(function () {
212
755
  cb(content);
213
756
  });
214
757
  } else {
@@ -257,6 +800,29 @@ export default function (options) {
257
800
  return match;
258
801
  }
259
802
 
803
+ /**
804
+ * Get a subtree by exact path equality.
805
+ * @param {Object} tree
806
+ * @param {String} path
807
+ * @returns {Object|undefined}
808
+ */
809
+ function getByExactPath(tree, path) {
810
+ let match;
811
+ if (tree) {
812
+ if (tree.path === path) {
813
+ match = tree;
814
+ } else if (tree.children) {
815
+ _.forEach(tree.children, function (child) {
816
+ match = getByExactPath(child, path);
817
+ if (match) {
818
+ return false;
819
+ }
820
+ });
821
+ }
822
+ }
823
+ return match;
824
+ }
825
+
260
826
  /**
261
827
  * Merge data into at into the subtree
262
828
  * @param {Object} tree - the tree model
@@ -300,8 +866,11 @@ export default function (options) {
300
866
  return child.path === path || (child.name && tree.path + child.name === path) || child.uri === path;
301
867
  });
302
868
  done = removed.length > 0;
303
- tree.total--;
304
- if (!done) {
869
+ if (done) {
870
+ if (Number.isFinite(Number(tree.total))) {
871
+ tree.total = Math.max(0, Number(tree.total) - removed.length);
872
+ }
873
+ } else {
305
874
  _.forEach(tree.children, function (child) {
306
875
  done = removeFromPath(child, path);
307
876
  if (done) {
@@ -313,31 +882,141 @@ export default function (options) {
313
882
  return done;
314
883
  }
315
884
 
885
+ /**
886
+ * Drop cached file rows for a folder so the next load hits the service
887
+ * with the current sort. Nested folder nodes are kept.
888
+ * @param {String} path
889
+ */
890
+ function invalidateFolderFiles(path) {
891
+ const content = getByExactPath(fileTree, path);
892
+ if (!content) {
893
+ return;
894
+ }
895
+ if (splitBrowse) {
896
+ delete content.listItems;
897
+ delete content.listCacheKey;
898
+ delete content.listPage;
899
+ return;
900
+ }
901
+ if (Array.isArray(content.children)) {
902
+ content.children = content.children.filter(function (child) {
903
+ return child.path && !child.uri;
904
+ });
905
+ }
906
+ }
907
+
908
+ /**
909
+ * Replace a folder node with a freshly loaded payload (files + folders).
910
+ * @param {String} path
911
+ * @param {Object} data
912
+ */
913
+ function replaceFolderContent(path, data, cacheKey) {
914
+ const content = getByExactPath(fileTree, path);
915
+ if (!content || !data) {
916
+ return;
917
+ }
918
+ if (splitBrowse && Object.prototype.hasOwnProperty.call(data, 'items')) {
919
+ applyListPayload(fileTree, path, data, cacheKey || listCacheKey());
920
+ return;
921
+ }
922
+ if (Object.prototype.hasOwnProperty.call(data, 'children')) {
923
+ content.children = data.children;
924
+ } else {
925
+ content.children = [];
926
+ content.empty = true;
927
+ content.total = 0;
928
+ }
929
+ if (Object.prototype.hasOwnProperty.call(data, 'total')) {
930
+ content.total = data.total;
931
+ }
932
+ if (Object.prototype.hasOwnProperty.call(data, 'childrenLimit')) {
933
+ content.childrenLimit = data.childrenLimit;
934
+ }
935
+ if (data.label) {
936
+ content.label = data.label;
937
+ }
938
+ if (data.path) {
939
+ content.path = data.path;
940
+ }
941
+ }
942
+
316
943
  /**
317
944
  * Get the content of a folder
318
945
  * @param {String} path - the folder path
319
- * @returns {jQuery.Deferred} the defferred object to run done/complete/fail
946
+ * @returns {Promise} resolves with folder content
320
947
  */
321
948
  function loadContent(path) {
322
949
  const parameters = {};
323
- parameters[options.pathParam] = path;
324
- return request({
325
- url: options.browseUrl,
326
- method: 'GET',
327
- dataType: 'json',
328
- data: _.merge(parameters, options.params, {
329
- childrenOffset: (selectedClass.page - 1) * selectedClass.childrenLimit
330
- }),
331
- noToken: true
332
- })
333
- .then(response => response.data)
334
- .then(response => {
335
- response = updatePermissions(response);
336
- if (response.children && response.children.length > 0) {
337
- response.children.map(responseChildren => updatePermissions(responseChildren));
950
+ parameters[options.pathParam || 'path'] = path;
951
+ setTreeLoading(true);
952
+ return Promise.resolve(
953
+ $.ajax({
954
+ url: options.browseUrl,
955
+ method: 'GET',
956
+ dataType: 'json',
957
+ timeout: ajaxTimeoutMs,
958
+ data: _.merge(parameters, options.params, {
959
+ // depth=2 so each rendered child folder includes its own dir children,
960
+ // allowing leaf folders to hide the expand chevron without an extra click.
961
+ depth: 2,
962
+ childrenOffset: (selectedClass.page - 1) * selectedClass.childrenLimit,
963
+ sortBy: sort.field,
964
+ sortDir: sort.direction
965
+ })
966
+ })
967
+ )
968
+ .then(function (response) {
969
+ if (response && response.success === false) {
970
+ return Promise.reject(response);
338
971
  }
339
- return response;
340
- });
972
+ let data = response && response.data ? response.data : response;
973
+ data = updatePermissions(data);
974
+ if (data.children && data.children.length > 0) {
975
+ data.children.map(responseChildren => updatePermissions(responseChildren));
976
+ }
977
+ return data;
978
+ })
979
+ .then(
980
+ function (data) {
981
+ setTreeLoading(false);
982
+ return data;
983
+ },
984
+ function (error) {
985
+ setTreeLoading(false);
986
+ return Promise.reject(error);
987
+ }
988
+ );
989
+ }
990
+
991
+ /**
992
+ * Whether a folder node has nested directory children.
993
+ * @param {Object} node
994
+ * @returns {boolean|null} true/false when known; null when children were not loaded yet
995
+ */
996
+ function hasNestedFolderChildren(node) {
997
+ if (!node || node.empty === true) {
998
+ return false;
999
+ }
1000
+ if (!Array.isArray(node.children)) {
1001
+ return null;
1002
+ }
1003
+ return Boolean(_.find(node.children, 'path'));
1004
+ }
1005
+
1006
+ /**
1007
+ * Show or hide the expand chevron on a folder anchor (keep spacer width).
1008
+ * @param {jQuery} $anchor
1009
+ * @param {boolean} hasNested
1010
+ */
1011
+ function setFolderToggleState($anchor, hasNested) {
1012
+ const $toggle = $anchor.children('.tree-toggle');
1013
+ if (hasNested) {
1014
+ $toggle.addClass('icon-right').removeClass('is-leaf');
1015
+ $anchor.removeClass('is-leaf');
1016
+ } else {
1017
+ $toggle.removeClass('icon-right').addClass('is-leaf');
1018
+ $anchor.removeClass('opened').addClass('is-leaf');
1019
+ }
341
1020
  }
342
1021
 
343
1022
  /**
@@ -351,7 +1030,10 @@ export default function (options) {
351
1030
  if (typeof data.relPath === 'undefined') {
352
1031
  data.relPath = data.path;
353
1032
  }
1033
+ // Unknown nested state (lazy depth) keeps the chevron until the folder is opened.
1034
+ data.showToggle = hasNestedFolderChildren(data) !== false;
354
1035
  $parent.append(folderTpl(data));
1036
+ return;
355
1037
  }
356
1038
  if (data && data.children && _.isArray(data.children) && !data.empty) {
357
1039
  _.forEach(data.children, function (child) {
@@ -376,7 +1058,7 @@ export default function (options) {
376
1058
  childrenLimit:
377
1059
  Number.isFinite(normalizedChildrenLimit) && normalizedChildrenLimit > 0
378
1060
  ? normalizedChildrenLimit
379
- : selectedClass.childrenLimit || 10,
1061
+ : selectedClass.childrenLimit || 11,
380
1062
  page: 1
381
1063
  };
382
1064
  }
@@ -384,7 +1066,11 @@ export default function (options) {
384
1066
  * Render pagination
385
1067
  */
386
1068
  function renderPagination() {
387
- const $paginationContainer = $('.pagination-bottom', $container);
1069
+ if (searchMode) {
1070
+ // Drop browse pagination controls/handlers when search owns the table.
1071
+ $paginationContainer.empty();
1072
+ return;
1073
+ }
388
1074
  const total = Number(selectedClass.total);
389
1075
  const childrenLimit = Number(selectedClass.childrenLimit);
390
1076
 
@@ -413,19 +1099,113 @@ export default function (options) {
413
1099
  .render($paginationContainer);
414
1100
  }
415
1101
  }
1102
+ /**
1103
+ * Re-fetch the current folder with the active sort and publish files.
1104
+ */
1105
+ function reloadSortedFolder(pendingUploads) {
1106
+ const listRequest = captureListRequestContext();
1107
+ const path = listRequest.path;
1108
+ if (!searchMode && isActiveBrowser()) {
1109
+ setFilesLoading(true);
1110
+ }
1111
+ const loadPromise = splitBrowse
1112
+ ? browseRequest(path, {
1113
+ part: 'list',
1114
+ page: selectedClass.page,
1115
+ pageSize: selectedClass.childrenLimit || 11,
1116
+ sortBy: sort.field,
1117
+ sortDir: sort.direction
1118
+ })
1119
+ : loadContent(path);
1120
+
1121
+ loadPromise
1122
+ .then(function (data) {
1123
+ if (!data) {
1124
+ setFilesLoading(false);
1125
+ return;
1126
+ }
1127
+ if (isListRequestStale(listRequest)) {
1128
+ setFilesLoading(false);
1129
+ return;
1130
+ }
1131
+ replaceFolderContent(path, data, listRequest.cacheKey);
1132
+ mergePendingUploads(path, pendingUploads);
1133
+ const content = getByExactPath(fileTree, path) || data;
1134
+ selectFolder(content, content.path || path);
1135
+ })
1136
+ .catch(function (error) {
1137
+ LOGGER.error(error);
1138
+ setFilesLoading(false);
1139
+ });
1140
+ }
1141
+
416
1142
  /**
417
1143
  * Load page
418
1144
  */
419
1145
  function loadPage() {
420
- const subTree = getByPath(fileTree, selectedClass.path);
1146
+ const listRequest = captureListRequestContext();
1147
+
1148
+ if (!searchMode && isActiveBrowser()) {
1149
+ setFilesLoading(true);
1150
+ }
1151
+
1152
+ if (splitBrowse) {
1153
+ const path = listRequest.path;
1154
+ browseRequest(path, {
1155
+ part: 'list',
1156
+ page: selectedClass.page,
1157
+ pageSize: selectedClass.childrenLimit || 11,
1158
+ sortBy: sort.field,
1159
+ sortDir: sort.direction
1160
+ })
1161
+ .then(function (data) {
1162
+ if (isListRequestStale(listRequest)) {
1163
+ setFilesLoading(false);
1164
+ return;
1165
+ }
1166
+ applyListPayload(fileTree, path, data, listRequest.cacheKey);
1167
+ const content = getByExactPath(fileTree, path);
1168
+ if (content) {
1169
+ $container.trigger(`folderselect.${NS}`, [
1170
+ content.label,
1171
+ getFilesForDisplay(content),
1172
+ content.path,
1173
+ content
1174
+ ]);
1175
+ renderPagination();
1176
+ }
1177
+ setFilesLoading(false);
1178
+ })
1179
+ .catch(function (error) {
1180
+ LOGGER.error(error);
1181
+ setFilesLoading(false);
1182
+ });
1183
+ return;
1184
+ }
1185
+
1186
+ const subTree = getByPath(fileTree, selectedClass.path) || fileTree;
421
1187
 
422
1188
  //get the folder content
423
1189
  getFolderContent(subTree, selectedClass.path, function (content) {
1190
+ if (isListRequestStale(listRequest)) {
1191
+ if (!searchMode) {
1192
+ setFilesLoading(false);
1193
+ }
1194
+ return;
1195
+ }
424
1196
  indexTree(fileTree);
425
1197
 
426
1198
  if (content) {
427
1199
  //internal event to set the file-selector content
428
- $container.trigger(`folderselect.${ns}`, [content.label, getPage(content.children), content.path]);
1200
+ $container.trigger(`folderselect.${NS}`, [
1201
+ content.label,
1202
+ getFilesForDisplay(content),
1203
+ content.path,
1204
+ content
1205
+ ]);
1206
+ }
1207
+ if (!searchMode) {
1208
+ setFilesLoading(false);
429
1209
  }
430
1210
  });
431
1211
  }