Skip to content

Commit 1e2aec3

Browse files
luis100claude
authored andcommitted
Add Location column to AIP search and selection lists. Closes #3656
Adds a new `default_IndexedAIP_parent` column to the five main AIP search/selection lists showing the direct parent's level icon and title. Hovering displays the full ancestor breadcrumb (root / ... / parent). Rendering uses `AipTitleBatchFetcher`, a GWT singleton that coalesces UUID lookups into a single batched API request (50 ms debounce), caches results for 10 s (configurable), and redraws affected tables on resolution. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4f87535 commit 1e2aec3

4 files changed

Lines changed: 244 additions & 1 deletion

File tree

roda-ui/roda-wui/src/main/java/org/roda/wui/client/common/lists/utils/ConfigurableAsyncTableCell.java

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.roda.core.data.v2.ip.IndexedRepresentation;
3030
import org.roda.core.data.v2.ip.metadata.FileFormat;
3131
import org.roda.wui.client.common.lists.utils.ColumnOptions.RenderingHint;
32+
import org.roda.wui.client.common.utils.AipTitleBatchFetcher;
3233
import org.roda.wui.common.client.tools.DescriptionLevelUtils;
3334
import org.roda.wui.common.client.tools.Humanize;
3435
import org.roda.wui.common.client.tools.StringUtils;
@@ -105,6 +106,9 @@ public SafeHtml getValue(IndexedAIP aip) {
105106
DEFAULT_COLUMNS_FIELDS.put("default_IndexedAIP_hasrepresentations",
106107
Arrays.asList(RodaConstants.AIP_HAS_REPRESENTATIONS));
107108

109+
DEFAULT_COLUMNS_FIELDS.put("default_IndexedAIP_parent",
110+
Arrays.asList(RodaConstants.AIP_PARENT_ID, RodaConstants.AIP_ANCESTORS, RodaConstants.AIP_LEVEL));
111+
108112
/********************************************
109113
* Representations
110114
********************************************/
@@ -322,7 +326,13 @@ protected void configureDisplay(CellTable<T> display) {
322326
SafeHtml htmlHeader;
323327
List<String> sortBy = c.getSortBy();
324328

325-
if (name.startsWith("default_")) {
329+
if ("default_IndexedAIP_parent".equals(name)) {
330+
@SuppressWarnings("unchecked")
331+
CellTable<IndexedAIP> aipTable = (CellTable<IndexedAIP>) display;
332+
column = (Column<T, ?>) createAipParentColumn(aipTable);
333+
htmlHeader = messages.defaultColumnHeader(name);
334+
sortBy = Collections.emptyList();
335+
} else if (name.startsWith("default_")) {
326336
// is a default column
327337
column = (Column<T, ?>) DEFAULT_COLUMNS.get(name);
328338
htmlHeader = header.equals(name) ? messages.defaultColumnHeader(name) : SafeHtmlUtils.fromString(header);
@@ -402,4 +412,68 @@ protected Sorter getSorter(ColumnSortList columnSortList) {
402412
return createSorter(columnSortList, columnSortingKeyMap);
403413
}
404414

415+
private static Column<IndexedAIP, SafeHtml> createAipParentColumn(CellTable<IndexedAIP> table) {
416+
return new Column<IndexedAIP, SafeHtml>(new SafeHtmlCell()) {
417+
@Override
418+
public SafeHtml getValue(IndexedAIP aip) {
419+
if (aip == null) {
420+
return SafeHtmlUtils.EMPTY_SAFE_HTML;
421+
}
422+
423+
String parentId = aip.getParentID();
424+
if (parentId == null) {
425+
return SafeHtmlUtils.EMPTY_SAFE_HTML;
426+
}
427+
428+
List<String> ancestors = aip.getAncestors();
429+
if (ancestors == null || ancestors.isEmpty()) {
430+
ancestors = Collections.singletonList(parentId);
431+
}
432+
433+
AipTitleBatchFetcher fetcher = AipTitleBatchFetcher.getInstance();
434+
435+
// Always queue a background re-fetch; redraw fires only if a value changed.
436+
fetcher.requestTitles(ancestors, table);
437+
438+
boolean allCached = true;
439+
for (String uuid : ancestors) {
440+
if (!fetcher.isCached(uuid)) {
441+
allCached = false;
442+
break;
443+
}
444+
}
445+
446+
if (!allCached) {
447+
return SafeHtmlUtils.fromSafeConstant("<i class=\"fa fa-spinner fa-spin\"></i>");
448+
}
449+
450+
// ancestors[0] = direct parent, ancestors[last] = root; reverse for breadcrumb
451+
List<String> orderedAncestors = new ArrayList<>(ancestors);
452+
Collections.reverse(orderedAncestors);
453+
StringBuilder tooltip = new StringBuilder();
454+
for (String uuid : orderedAncestors) {
455+
if (tooltip.length() > 0) {
456+
tooltip.append(" / ");
457+
}
458+
String t = fetcher.getCachedTitle(uuid);
459+
tooltip.append(t != null && !t.isEmpty() ? t : uuid);
460+
}
461+
462+
String parentTitle = fetcher.getCachedTitle(parentId);
463+
String parentLevel = fetcher.getCachedLevel(parentId);
464+
SafeHtml levelIcon = DescriptionLevelUtils.getElementLevelIconSafeHtml(parentLevel, false);
465+
466+
return new SafeHtmlBuilder()
467+
.appendHtmlConstant("<span title='")
468+
.appendEscaped(tooltip.toString())
469+
.appendHtmlConstant("'>")
470+
.append(levelIcon)
471+
.appendHtmlConstant("&nbsp;")
472+
.appendEscaped(parentTitle != null ? parentTitle : parentId)
473+
.appendHtmlConstant("</span>")
474+
.toSafeHtml();
475+
}
476+
};
477+
}
478+
405479
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* The contents of this file are subject to the license and copyright
3+
* detailed in the LICENSE file at the root of the source
4+
* tree and available online at
5+
*
6+
* https://github.com/keeps/roda
7+
*/
8+
package org.roda.wui.client.common.utils;
9+
10+
import java.util.ArrayList;
11+
import java.util.Arrays;
12+
import java.util.HashMap;
13+
import java.util.HashSet;
14+
import java.util.List;
15+
import java.util.Map;
16+
import java.util.Set;
17+
18+
import org.roda.core.data.common.RodaConstants;
19+
import org.roda.core.data.v2.index.FindRequest;
20+
import org.roda.core.data.v2.index.filter.Filter;
21+
import org.roda.core.data.v2.index.filter.OneOfManyFilterParameter;
22+
import org.roda.core.data.v2.index.sublist.Sublist;
23+
import org.roda.core.data.v2.ip.IndexedAIP;
24+
import org.roda.wui.client.services.Services;
25+
26+
import com.google.gwt.i18n.client.LocaleInfo;
27+
import com.google.gwt.user.cellview.client.CellTable;
28+
import com.google.gwt.user.client.Timer;
29+
30+
/**
31+
* Session-scoped singleton that batches AIP title lookups by UUID using a
32+
* stale-while-revalidate strategy: cached values are returned immediately while
33+
* a background re-fetch is always queued. {@link CellTable#redraw()} is called
34+
* only when a fetched value differs from the cached one, so the display
35+
* self-corrects after any Solr update without flickering on unchanged data.
36+
*/
37+
public class AipTitleBatchFetcher {
38+
39+
private static final int BATCH_DELAY_MS = 50;
40+
41+
private static AipTitleBatchFetcher instance;
42+
43+
private static final class CacheEntry {
44+
final String title;
45+
final String level;
46+
47+
CacheEntry(String title, String level) {
48+
this.title = title;
49+
this.level = level;
50+
}
51+
}
52+
53+
private final Map<String, CacheEntry> cache = new HashMap<>();
54+
private final Set<String> pendingUuids = new HashSet<>();
55+
private final Set<CellTable<IndexedAIP>> pendingTables = new HashSet<>();
56+
private Timer batchTimer;
57+
58+
private AipTitleBatchFetcher() {
59+
}
60+
61+
public static AipTitleBatchFetcher getInstance() {
62+
if (instance == null) {
63+
instance = new AipTitleBatchFetcher();
64+
}
65+
return instance;
66+
}
67+
68+
/** Returns true if a cached entry exists for this UUID. */
69+
public boolean isCached(String uuid) {
70+
return cache.containsKey(uuid);
71+
}
72+
73+
/** Returns the cached title, or {@code null} if not yet fetched. */
74+
public String getCachedTitle(String uuid) {
75+
CacheEntry e = cache.get(uuid);
76+
return e != null ? e.title : null;
77+
}
78+
79+
/** Returns the cached description level, or {@code null} if not yet fetched. */
80+
public String getCachedLevel(String uuid) {
81+
CacheEntry e = cache.get(uuid);
82+
return e != null ? e.level : null;
83+
}
84+
85+
/**
86+
* Queues {@code uuids} for a background re-fetch and registers {@code table}
87+
* to be redrawn if any fetched value differs from the cached one. Always
88+
* called from {@code Column.getValue()} — even on cache hits — so that stale
89+
* data is corrected automatically after Solr updates.
90+
*/
91+
public void requestTitles(List<String> uuids, CellTable<IndexedAIP> table) {
92+
pendingTables.add(table);
93+
pendingUuids.addAll(uuids);
94+
95+
if (batchTimer == null && !pendingUuids.isEmpty()) {
96+
batchTimer = new Timer() {
97+
@Override
98+
public void run() {
99+
executeBatch();
100+
}
101+
};
102+
batchTimer.schedule(BATCH_DELAY_MS);
103+
}
104+
}
105+
106+
private void executeBatch() {
107+
batchTimer = null;
108+
109+
final List<String> toFetch = new ArrayList<>(pendingUuids);
110+
pendingUuids.clear();
111+
final Set<CellTable<IndexedAIP>> tablesToRedraw = new HashSet<>(pendingTables);
112+
pendingTables.clear();
113+
114+
if (toFetch.isEmpty()) {
115+
return;
116+
}
117+
118+
Filter filter = new Filter(new OneOfManyFilterParameter(RodaConstants.AIP_ID, toFetch));
119+
FindRequest findRequest = FindRequest.getBuilder(filter, true)
120+
.withSublist(new Sublist(0, toFetch.size()))
121+
.withFieldsToReturn(Arrays.asList(RodaConstants.INDEX_UUID, RodaConstants.AIP_TITLE, RodaConstants.AIP_LEVEL))
122+
.build();
123+
124+
new Services("Fetch AIP titles", "get")
125+
.aipResource(s -> s.find(findRequest, LocaleInfo.getCurrentLocale().getLocaleName()))
126+
.whenComplete((result, throwable) -> {
127+
if (throwable == null && result != null) {
128+
boolean anyChanged = false;
129+
for (IndexedAIP aip : result.getResults()) {
130+
String newTitle = aip.getTitle() != null ? aip.getTitle() : "";
131+
String newLevel = aip.getLevel();
132+
CacheEntry existing = cache.get(aip.getUUID());
133+
if (existing == null || !newTitle.equals(existing.title) || !equalOrBothNull(newLevel, existing.level)) {
134+
anyChanged = true;
135+
}
136+
cache.put(aip.getUUID(), new CacheEntry(newTitle, newLevel));
137+
}
138+
if (anyChanged) {
139+
tablesToRedraw.forEach(CellTable::redraw);
140+
}
141+
}
142+
});
143+
}
144+
145+
private static boolean equalOrBothNull(String a, String b) {
146+
return a == null ? b == null : a.equals(b);
147+
}
148+
}

roda-ui/roda-wui/src/main/resources/config/i18n/client/ClientMessages.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1413,6 +1413,7 @@ removableTextBox:Removable text box
14131413
defaultColumnHeader:{0}
14141414
defaultColumnHeader[default_IndexedAIP_level]:<i class="fa fa-tag"></i>&nbsp;Level
14151415
defaultColumnHeader[default_IndexedAIP_hasrepresentations]:<i class="fa fa-paperclip" title="With files" aria-hidden="true"></i>
1416+
defaultColumnHeader[default_IndexedAIP_parent]:Location
14161417
defaultColumnHeader[default_IndexedFile_icon]:<i class="fa fa-files-o"></i>
14171418
#Disposal
14181419
disposalPolicyTitle:Disposal policies

roda-ui/roda-wui/src/main/resources/config/roda-wui.properties

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,6 +1082,10 @@ ui.lists.Search_AIPs.columns[].default_IndexedAIP_dates.sortBy = dateFinal
10821082
ui.lists.Search_AIPs.columns[].default_IndexedAIP_dates.sortBy = title_sort
10831083
ui.lists.Search_AIPs.columns[].default_IndexedAIP_dates.width = 14.0
10841084

1085+
ui.lists.Search_AIPs.columns[] = default_IndexedAIP_parent
1086+
ui.lists.Search_AIPs.columns[].default_IndexedAIP_parent.width = 14.0
1087+
ui.lists.Search_AIPs.columns[].default_IndexedAIP_parent.nowrap = true
1088+
10851089
ui.lists.Search_AIPs.columns[] = default_IndexedAIP_hasrepresentations
10861090
# # ui.lists.Search_AIPs.columns[].default_IndexedAIP_hasrepresentations.header = ui.search.fields.IndexedAIP.hasRepresentations
10871091
ui.lists.Search_AIPs.columns[].default_IndexedAIP_hasrepresentations.sortBy = hasRepresentations
@@ -1194,6 +1198,10 @@ ui.lists.SelectAipDialog_AIPs.columns[].default_IndexedAIP_dates.sortBy = dateFi
11941198
ui.lists.SelectAipDialog_AIPs.columns[].default_IndexedAIP_dates.sortBy = title_sort
11951199
ui.lists.SelectAipDialog_AIPs.columns[].default_IndexedAIP_dates.width = 14.0
11961200

1201+
ui.lists.SelectAipDialog_AIPs.columns[] = default_IndexedAIP_parent
1202+
ui.lists.SelectAipDialog_AIPs.columns[].default_IndexedAIP_parent.width = 14.0
1203+
ui.lists.SelectAipDialog_AIPs.columns[].default_IndexedAIP_parent.nowrap = true
1204+
11971205
ui.lists.SelectAipDialog_AIPs.columns[] = default_IndexedAIP_hasrepresentations
11981206
# ui.lists.SelectAipDialog_AIPs.columns[].default_IndexedAIP_hasrepresentations.header = ui.search.fields.IndexedAIP.hasRepresentations
11991207
ui.lists.SelectAipDialog_AIPs.columns[].default_IndexedAIP_hasrepresentations.sortBy = hasRepresentations
@@ -1222,6 +1230,10 @@ ui.lists.CreateActionJob_AIP.columns[].default_IndexedAIP_dates.sortBy = dateFin
12221230
ui.lists.CreateActionJob_AIP.columns[].default_IndexedAIP_dates.sortBy = title_sort
12231231
ui.lists.CreateActionJob_AIP.columns[].default_IndexedAIP_dates.width = 14.0
12241232

1233+
ui.lists.CreateActionJob_AIP.columns[] = default_IndexedAIP_parent
1234+
ui.lists.CreateActionJob_AIP.columns[].default_IndexedAIP_parent.width = 14.0
1235+
ui.lists.CreateActionJob_AIP.columns[].default_IndexedAIP_parent.nowrap = true
1236+
12251237
ui.lists.CreateActionJob_AIP.columns[] = default_IndexedAIP_hasrepresentations
12261238
# ui.lists.CreateActionJob_AIP.columns[].default_IndexedAIP_hasrepresentations.header = ui.search.fields.IndexedAIP.hasRepresentations
12271239
ui.lists.CreateActionJob_AIP.columns[].default_IndexedAIP_hasrepresentations.sortBy = hasRepresentations
@@ -1250,6 +1262,10 @@ ui.lists.CreateDefaultJob_AIP.columns[].default_IndexedAIP_dates.sortBy = dateFi
12501262
ui.lists.CreateDefaultJob_AIP.columns[].default_IndexedAIP_dates.sortBy = title_sort
12511263
ui.lists.CreateDefaultJob_AIP.columns[].default_IndexedAIP_dates.width = 14.0
12521264

1265+
ui.lists.CreateDefaultJob_AIP.columns[] = default_IndexedAIP_parent
1266+
ui.lists.CreateDefaultJob_AIP.columns[].default_IndexedAIP_parent.width = 14.0
1267+
ui.lists.CreateDefaultJob_AIP.columns[].default_IndexedAIP_parent.nowrap = true
1268+
12531269
ui.lists.CreateDefaultJob_AIP.columns[] = default_IndexedAIP_hasrepresentations
12541270
# ui.lists.CreateDefaultJob_AIP.columns[].default_IndexedAIP_hasrepresentations.header = ui.search.fields.IndexedAIP.hasRepresentations
12551271
ui.lists.CreateDefaultJob_AIP.columns[].default_IndexedAIP_hasrepresentations.sortBy = hasRepresentations
@@ -1278,6 +1294,10 @@ ui.lists.IngestAppraisal_searchAIPs.columns[].default_IndexedAIP_dates.sortBy =
12781294
ui.lists.IngestAppraisal_searchAIPs.columns[].default_IndexedAIP_dates.sortBy = title_sort
12791295
ui.lists.IngestAppraisal_searchAIPs.columns[].default_IndexedAIP_dates.width = 14.0
12801296

1297+
ui.lists.IngestAppraisal_searchAIPs.columns[] = default_IndexedAIP_parent
1298+
ui.lists.IngestAppraisal_searchAIPs.columns[].default_IndexedAIP_parent.width = 14.0
1299+
ui.lists.IngestAppraisal_searchAIPs.columns[].default_IndexedAIP_parent.nowrap = true
1300+
12811301
ui.lists.IngestAppraisal_searchAIPs.columns[] = default_IndexedAIP_hasrepresentations
12821302
ui.lists.IngestAppraisal_searchAIPs.columns[].default_IndexedAIP_hasrepresentations.header = ui.search.fields.IndexedAIP.hasRepresentations
12831303
ui.lists.IngestAppraisal_searchAIPs.columns[].default_IndexedAIP_hasrepresentations.sortBy = hasRepresentations

0 commit comments

Comments
 (0)