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