@oat-sa/tao-core-ui 3.23.0 → 3.24.0

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 +1137 -97
  8. package/dist/resourcemgr/css/resourcemgr.css.map +1 -1
  9. package/dist/resourcemgr/fileBrowser.js +456 -79
  10. package/dist/resourcemgr/filePreview.js +40 -4
  11. package/dist/resourcemgr/fileSelector.js +362 -92
  12. package/dist/resourcemgr.js +309 -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 +1137 -97
  20. package/src/resourcemgr/css/resourcemgr.css.map +1 -1
  21. package/src/resourcemgr/fileBrowser.js +470 -91
  22. package/src/resourcemgr/filePreview.js +46 -4
  23. package/src/resourcemgr/fileSelector.js +338 -46
  24. package/src/resourcemgr/scss/resourcemgr.scss +1351 -88
  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 +113 -54
  29. package/src/resourcemgr/tpl/rootFolder.tpl +3 -1
  30. package/src/resourcemgr.js +289 -47
  31. package/src/searchModal/advancedSearch.js +242 -31
@@ -0,0 +1,477 @@
1
+ define(['exports'], function (exports) { 'use strict';
2
+
3
+ /**
4
+ * This program is free software; you can redistribute it and/or
5
+ * modify it under the terms of the GNU General Public License
6
+ * as published by the Free Software Foundation; under version 2
7
+ * of the License (non-upgradable).
8
+ *
9
+ * This program is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU General Public License
15
+ * along with this program; if not, write to the Free Software
16
+ * Foundation, Inc., 31 Milk St # 960789 Boston, MA 02196 USA.
17
+ *
18
+ * Copyright (c) 2026 (original work) Open Assessment Technologies SA;
19
+ */
20
+
21
+ /**
22
+ * FE contract for scoped asset search used by Resource Manager (PoC).
23
+ * Align request/response fields with the backend search ticket.
24
+ *
25
+ * @exports ui/resourcemgr/assetSearchContract
26
+ */
27
+
28
+ /**
29
+ * Supported sort fields for the picker search UI.
30
+ * @type {{LABEL: string, LOCATION: string, UPDATED_AT: string}}
31
+ */
32
+ const SORT_FIELDS = {
33
+ LABEL: 'label',
34
+ LOCATION: 'location',
35
+ UPDATED_AT: 'updatedAt'
36
+ };
37
+
38
+ /**
39
+ * Default sort: Label ascending (case-insensitive on the service side).
40
+ * @type {{field: string, direction: string}}
41
+ */
42
+ const DEFAULT_SORT = {
43
+ field: SORT_FIELDS.LABEL,
44
+ direction: 'asc'
45
+ };
46
+
47
+ /**
48
+ * Default page size for search results.
49
+ * @type {number}
50
+ */
51
+ const DEFAULT_PAGE_SIZE = 10;
52
+
53
+ /**
54
+ * Minimum universal query length before Search is enabled (matches indexed prefix rules).
55
+ * @type {number}
56
+ */
57
+ const MIN_TEXT_QUERY_LENGTH = 3;
58
+
59
+ /**
60
+ * Whether a rendered metadata criterion has a usable value.
61
+ * @param {Object} criterion
62
+ * @returns {boolean}
63
+ */
64
+ function isMetadataCriterionComplete(criterion) {
65
+ if (!criterion || !criterion.rendered) {
66
+ return true;
67
+ }
68
+ if (criterion.type === 'text') {
69
+ return String(criterion.value || '').trim() !== '';
70
+ }
71
+ if (criterion.type === 'list') {
72
+ if (!Array.isArray(criterion.value)) {
73
+ return false;
74
+ }
75
+ return criterion.value.some(function (entry) {
76
+ return entry !== '' && entry !== null && typeof entry !== 'undefined';
77
+ });
78
+ }
79
+ return true;
80
+ }
81
+
82
+ /**
83
+ * True when any rendered metadata card is missing a value.
84
+ * @param {Object} [criteriaState]
85
+ * @returns {boolean}
86
+ */
87
+ function hasIncompleteRenderedMetadata(criteriaState) {
88
+ if (!criteriaState || typeof criteriaState !== 'object') {
89
+ return false;
90
+ }
91
+ return Object.keys(criteriaState).some(function (key) {
92
+ const criterion = criteriaState[key];
93
+ return criterion && criterion.rendered && !isMetadataCriterionComplete(criterion);
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Whether Search should be enabled for the current pending input.
99
+ * Unlock when universal query (≥ min length) **or** complete metadata is present (OR).
100
+ * Any rendered filter card without a value blocks until fixed or removed.
101
+ *
102
+ * @param {string} textQuery - universal search field (trimmed by caller)
103
+ * @param {Object} [criteriaState] - advancedSearch.getState()
104
+ * @returns {boolean}
105
+ */
106
+ function canSubmitScopedAssetSearch(textQuery, criteriaState) {
107
+ const text = String(textQuery || '').trim();
108
+ if (hasIncompleteRenderedMetadata(criteriaState)) {
109
+ return false;
110
+ }
111
+ const metadata = buildMetadataFromCriteriaState(criteriaState);
112
+ const hasValidMetadata = Object.keys(metadata).length > 0;
113
+ const hasValidQuery = text.length >= MIN_TEXT_QUERY_LENGTH;
114
+ return hasValidQuery || hasValidMetadata;
115
+ }
116
+
117
+ /**
118
+ * Build query params for a scoped asset-search request.
119
+ *
120
+ * Request minimum:
121
+ * - path/scope (folder subtree)
122
+ * - query text (universal search only; metadata is separate)
123
+ * - optional metadata map (propertyUri → value)
124
+ * - sort field + direction
125
+ * - page / pageSize
126
+ * - existing picker params (uri, lang, filters) passed through as-is
127
+ *
128
+ * Metadata filters are sent as nested `metadata[propertyUri]=value` (jQuery
129
+ * serializes a plain object). Independently supplied properties combine with AND
130
+ * on the API. Universal `query` and metadata also combine with AND.
131
+ *
132
+ * @param {Object} options
133
+ * @param {string} options.path - folder scope
134
+ * @param {string} options.query - universal search text only
135
+ * @param {Object.<string, string>} [options.metadata] - property URI → value
136
+ * @param {{field: string, direction: string}} options.sort
137
+ * @param {number} options.page - 1-based page
138
+ * @param {number} [options.pageSize]
139
+ * @param {string} [options.pathParam='path']
140
+ * @param {Object} [options.params] - existing RM params (uri, lang, filters, …)
141
+ * @returns {Object}
142
+ */
143
+ function buildSearchRequestParams(options) {
144
+ const pathParam = options.pathParam || 'path';
145
+ const pageSize = options.pageSize || DEFAULT_PAGE_SIZE;
146
+ const sort = options.sort || DEFAULT_SORT;
147
+ const params = Object.assign({}, options.params || {});
148
+ params[pathParam] = options.path;
149
+ params.query = options.query || '';
150
+ params.sortBy = sort.field;
151
+ params.sortDir = sort.direction;
152
+ params.page = options.page || 1;
153
+ params.pageSize = pageSize;
154
+ const metadata = normalizeMetadataMap(options.metadata);
155
+ if (Object.keys(metadata).length) {
156
+ params.metadata = metadata;
157
+ }
158
+ return params;
159
+ }
160
+
161
+ /**
162
+ * Build a flat metadata map from advancedSearch.getState().
163
+ * Text criteria use trimmed string values; list criteria use the first
164
+ * non-empty selected value only.
165
+ *
166
+ * Follow-up: multi-value list criteria and LOGIC_AND/OR/NOT require BE contract
167
+ * changes (flat metadata[propertyUri]=value supports AND-only today). See HKD-1996.
168
+ *
169
+ * @param {Object} [criteriaState]
170
+ * @returns {Object.<string, string>}
171
+ */
172
+ function buildMetadataFromCriteriaState(criteriaState) {
173
+ const metadata = {};
174
+ if (!criteriaState || typeof criteriaState !== 'object') {
175
+ return metadata;
176
+ }
177
+ Object.keys(criteriaState).forEach(function (key) {
178
+ const criterion = criteriaState[key];
179
+ if (!criterion || !criterion.rendered || !criterion.propertyUri) {
180
+ return;
181
+ }
182
+ if (criterion.type === 'text') {
183
+ const value = String(criterion.value || '').trim();
184
+ if (value) {
185
+ metadata[criterion.propertyUri] = value;
186
+ }
187
+ return;
188
+ }
189
+ if (criterion.type === 'list' && Array.isArray(criterion.value)) {
190
+ const first = criterion.value.find(function (entry) {
191
+ return entry !== '' && entry !== null && typeof entry !== 'undefined';
192
+ });
193
+ if (typeof first !== 'undefined' && first !== null && first !== '') {
194
+ metadata[criterion.propertyUri] = String(first);
195
+ }
196
+ }
197
+ });
198
+ return metadata;
199
+ }
200
+
201
+ /**
202
+ * @param {Object} [metadata]
203
+ * @returns {Object.<string, string>}
204
+ */
205
+ function normalizeMetadataMap(metadata) {
206
+ const normalized = {};
207
+ if (!metadata || typeof metadata !== 'object') {
208
+ return normalized;
209
+ }
210
+ Object.keys(metadata).forEach(function (uri) {
211
+ const value = metadata[uri];
212
+ if (typeof value === 'string' && value !== '') {
213
+ normalized[uri] = value;
214
+ }
215
+ });
216
+ return normalized;
217
+ }
218
+
219
+ /**
220
+ * Normalize a search service payload into a stable picker shape.
221
+ *
222
+ * Response minimum for PoC rows:
223
+ * - identity/uri, label/name, mime/type, location, updatedAt, permissions
224
+ * - total + pageSize for pagination
225
+ * Empty success (total 0 / empty items) is distinct from request failure.
226
+ *
227
+ * Accepts either a wrapped `{ data: … }` response or a flat body, and either
228
+ * `items` or browse-compatible `children`.
229
+ *
230
+ * @param {Object} payload
231
+ * @returns {{items: Array, total: number, page: number, pageSize: number}}
232
+ */
233
+ function normalizeSearchResponse(payload) {
234
+ const body = unwrapPayload(payload);
235
+ const rawItems = body.items || body.children || [];
236
+ const items = (Array.isArray(rawItems) ? rawItems : []).map(normalizeSearchItem);
237
+ const pageSize = Number(body.pageSize || body.childrenLimit || DEFAULT_PAGE_SIZE);
238
+ const page = Number(body.page || 1);
239
+ const rawTotal = Number(body.total);
240
+ const total = typeof body.total === 'undefined' || body.total === null || !Number.isFinite(rawTotal) ? items.length : rawTotal;
241
+ return {
242
+ items,
243
+ total,
244
+ page: Number.isFinite(page) && page > 0 ? page : 1,
245
+ pageSize: Number.isFinite(pageSize) && pageSize > 0 ? pageSize : DEFAULT_PAGE_SIZE
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Browse (`ItemContent/files`) responses use `children` and ignore `query`.
251
+ * Real search responses expose `items`.
252
+ *
253
+ * @param {Object} payload
254
+ * @returns {boolean}
255
+ */
256
+ function isBrowseShapedSearchPayload(payload) {
257
+ const body = unwrapPayload(payload);
258
+ return Array.isArray(body.children) && !Array.isArray(body.items);
259
+ }
260
+
261
+ /**
262
+ * Local filter/sort/page for browse-shaped payloads (`children`, no `items`).
263
+ * Search endpoints that return `items` are left untouched.
264
+ * MIME eligibility stays server-side (AC: no client-side MIME decisions).
265
+ *
266
+ * @param {Object} normalized - output of normalizeSearchResponse
267
+ * @param {Object} options
268
+ * @param {string} options.query
269
+ * @param {{field: string, direction: string}} [options.sort]
270
+ * @param {number} [options.page]
271
+ * @param {number} [options.pageSize]
272
+ * @returns {{items: Array, total: number, page: number, pageSize: number}}
273
+ */
274
+ function applyLocalSearchFallback(normalized, options) {
275
+ const metadata = normalizeMetadataMap(options && options.metadata);
276
+ if (Object.keys(metadata).length) {
277
+ const pageSize = Number(options && options.pageSize) > 0 ? Number(options.pageSize) : normalized.pageSize || DEFAULT_PAGE_SIZE;
278
+ return {
279
+ items: [],
280
+ total: 0,
281
+ page: 1,
282
+ pageSize,
283
+ metadataUnsupported: true
284
+ };
285
+ }
286
+ const query = String(options && options.query || '').trim().toLowerCase();
287
+ const sort = options && options.sort || DEFAULT_SORT;
288
+ const pageSize = Number(options && options.pageSize) > 0 ? Number(options.pageSize) : normalized.pageSize || DEFAULT_PAGE_SIZE;
289
+ const page = Number(options && options.page) > 0 ? Number(options.page) : 1;
290
+ let items = (normalized.items || []).filter(isSearchableAsset);
291
+ if (query) {
292
+ const queryTokens = tokenizeSearchText(query);
293
+ if (queryTokens.length === 0) {
294
+ // Delimiter-only query: no searchable tokens → empty (matches BE).
295
+ items = [];
296
+ } else {
297
+ items = items.filter(function (item) {
298
+ return matchesQueryTokens(getSearchableText(item), queryTokens);
299
+ });
300
+ }
301
+ }
302
+ items = sortAssetItems(items, sort);
303
+ const total = items.length;
304
+ const safePageSize = pageSize > 0 ? pageSize : DEFAULT_PAGE_SIZE;
305
+ const maxPage = Math.max(1, Math.ceil(total / safePageSize) || 1);
306
+ const safePage = Math.min(page, maxPage);
307
+ const start = (safePage - 1) * safePageSize;
308
+ return {
309
+ items: items.slice(start, start + safePageSize),
310
+ total,
311
+ page: safePage,
312
+ pageSize: safePageSize
313
+ };
314
+ }
315
+
316
+ /**
317
+ * @param {Object} payload
318
+ * @returns {Object}
319
+ */
320
+ function unwrapPayload(payload) {
321
+ return payload && payload.data && !Array.isArray(payload.data) ? payload.data : payload || {};
322
+ }
323
+
324
+ /**
325
+ * @param {Object} item
326
+ * @returns {Object}
327
+ */
328
+ function normalizeSearchItem(item) {
329
+ const normalized = Object.assign({}, item);
330
+ if (!normalized.name && normalized.label) {
331
+ normalized.name = normalized.label;
332
+ }
333
+ if (!normalized.label && normalized.name) {
334
+ normalized.label = normalized.name;
335
+ }
336
+ if (!normalized.uri && normalized.id) {
337
+ normalized.uri = normalized.id;
338
+ }
339
+ if (!normalized.updatedAt && normalized.updated_at) {
340
+ normalized.updatedAt = normalized.updated_at;
341
+ }
342
+ if (!normalized.location && normalized.path) {
343
+ normalized.location = normalized.path;
344
+ }
345
+ return normalized;
346
+ }
347
+
348
+ /**
349
+ * @param {Object} item
350
+ * @returns {boolean}
351
+ */
352
+ function isSearchableAsset(item) {
353
+ if (!item) {
354
+ return false;
355
+ }
356
+ if (item.mime || item.type) {
357
+ return true;
358
+ }
359
+ // Directories from browse have nested children / no mime.
360
+ if (Array.isArray(item.children) || item.path && !item.uri && !item.file) {
361
+ return false;
362
+ }
363
+ return Boolean(item.uri || item.file || item.name || item.label);
364
+ }
365
+
366
+ /**
367
+ * @param {Object} item
368
+ * @returns {string}
369
+ */
370
+ function getSearchableText(item) {
371
+ return [item.label, item.name, item.location, item.path, item.uri, item.file].filter(Boolean).join(' ').toLowerCase();
372
+ }
373
+
374
+ /**
375
+ * Split search text into lowercase alphanumeric tokens (aligned with BE
376
+ * AssetSearchBuilder::tokenize).
377
+ *
378
+ * @param {string} value
379
+ * @returns {string[]}
380
+ */
381
+ function tokenizeSearchText(value) {
382
+ const normalized = String(value || '').trim().toLowerCase();
383
+ if (!normalized) {
384
+ return [];
385
+ }
386
+ return normalized.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
387
+ }
388
+
389
+ /**
390
+ * Every query token must be a prefix of at least one haystack token (AND).
391
+ *
392
+ * @param {string} haystackText
393
+ * @param {string[]} queryTokens
394
+ * @returns {boolean}
395
+ */
396
+ function matchesQueryTokens(haystackText, queryTokens) {
397
+ const haystackTokens = tokenizeSearchText(haystackText);
398
+ if (haystackTokens.length === 0) {
399
+ return false;
400
+ }
401
+ return queryTokens.every(function (token) {
402
+ return haystackTokens.some(function (candidate) {
403
+ return candidate.indexOf(token) === 0;
404
+ });
405
+ });
406
+ }
407
+
408
+ /**
409
+ * Sort asset rows by the picker contract (label / location / updatedAt).
410
+ * Equal keys fall back to label so the order is stable when a column is tied.
411
+ *
412
+ * @param {Array} items
413
+ * @param {{field: string, direction: string}} [sort]
414
+ * @returns {Array}
415
+ */
416
+ function sortAssetItems(items, sort) {
417
+ const resolved = Object.assign({}, DEFAULT_SORT, sort || {});
418
+ return (items || []).slice().sort(function (a, b) {
419
+ const primary = compareSortValues(getSortValue(a, resolved.field), getSortValue(b, resolved.field), resolved.direction);
420
+ if (primary !== 0) {
421
+ return primary;
422
+ }
423
+ if (resolved.field === SORT_FIELDS.LABEL) {
424
+ return 0;
425
+ }
426
+ return compareSortValues(getSortValue(a, SORT_FIELDS.LABEL), getSortValue(b, SORT_FIELDS.LABEL), 'asc');
427
+ });
428
+ }
429
+
430
+ /**
431
+ * @param {string} left
432
+ * @param {string} right
433
+ * @param {string} direction
434
+ * @returns {number}
435
+ */
436
+ function compareSortValues(left, right, direction) {
437
+ if (left < right) {
438
+ return direction === 'desc' ? 1 : -1;
439
+ }
440
+ if (left > right) {
441
+ return direction === 'desc' ? -1 : 1;
442
+ }
443
+ return 0;
444
+ }
445
+
446
+ /**
447
+ * @param {Object} item
448
+ * @param {string} field
449
+ * @returns {string}
450
+ */
451
+ function getSortValue(item, field) {
452
+ if (field === SORT_FIELDS.LOCATION) {
453
+ return String(item.location || item.path || '').toLowerCase();
454
+ }
455
+ if (field === SORT_FIELDS.UPDATED_AT) {
456
+ return String(item.updatedAt || item.updated_at || '');
457
+ }
458
+ return String(item.label || item.name || '').toLowerCase();
459
+ }
460
+
461
+ exports.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
462
+ exports.DEFAULT_SORT = DEFAULT_SORT;
463
+ exports.MIN_TEXT_QUERY_LENGTH = MIN_TEXT_QUERY_LENGTH;
464
+ exports.SORT_FIELDS = SORT_FIELDS;
465
+ exports.applyLocalSearchFallback = applyLocalSearchFallback;
466
+ exports.buildMetadataFromCriteriaState = buildMetadataFromCriteriaState;
467
+ exports.buildSearchRequestParams = buildSearchRequestParams;
468
+ exports.canSubmitScopedAssetSearch = canSubmitScopedAssetSearch;
469
+ exports.hasIncompleteRenderedMetadata = hasIncompleteRenderedMetadata;
470
+ exports.isBrowseShapedSearchPayload = isBrowseShapedSearchPayload;
471
+ exports.isMetadataCriterionComplete = isMetadataCriterionComplete;
472
+ exports.normalizeSearchResponse = normalizeSearchResponse;
473
+ exports.sortAssetItems = sortAssetItems;
474
+
475
+ Object.defineProperty(exports, '__esModule', { value: true });
476
+
477
+ });