From a229337314ff0b68388f084913f9f81d072a5419 Mon Sep 17 00:00:00 2001 From: MartinKranec Date: Thu, 29 Oct 2026 11:53:17 +0100 Subject: [PATCH 1/2] [NAE-2502] - Dashboard items: updateDashboardItem fails on an existing tile, and itemIcon / name from the API are ignored - Modify `findCasesElastic` usage to prevent duplicate dashboard item IDs. - Implement batch processing for dataset updates with error handling rollback. - Add data splitting by configuration phases to manage dashboard item updates. - Enhance null-check for `id` in `DashboardItemBody.toDataSet`. - Clean up unused imports and improve method documentation. --- .../services/DashboardItemServiceImpl.java | 73 +++++++++++++++---- .../engine-processes/dashboard_item.xml | 8 +- .../menu/dashboard/DashboardItemBody.java | 4 +- 3 files changed, 66 insertions(+), 19 deletions(-) diff --git a/application-engine/src/main/java/com/netgrif/application/engine/menu/services/DashboardItemServiceImpl.java b/application-engine/src/main/java/com/netgrif/application/engine/menu/services/DashboardItemServiceImpl.java index dc57d15c961..4312d470bb7 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/menu/services/DashboardItemServiceImpl.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/menu/services/DashboardItemServiceImpl.java @@ -8,9 +8,6 @@ import com.netgrif.application.engine.objects.auth.domain.AbstractUser; import com.netgrif.application.engine.objects.auth.domain.ActorTransformer; import com.netgrif.application.engine.objects.auth.domain.LoggedUser; -import com.netgrif.application.engine.objects.common.ResourceNotFoundException; -import com.netgrif.application.engine.objects.common.ResourceNotFoundExceptionCode; -import com.netgrif.application.engine.objects.petrinet.domain.PetriNet; import com.netgrif.application.engine.objects.petrinet.domain.throwable.TransitionNotExecutableException; import com.netgrif.application.engine.objects.utils.MenuItemUtils; import com.netgrif.application.engine.objects.workflow.domain.Case; @@ -31,9 +28,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; -import java.util.Collections; -import java.util.Locale; -import java.util.Map; +import java.util.*; @Slf4j @Service @@ -74,7 +69,7 @@ public Case getOrCreate(DashboardItemBody body) throws TransitionNotExecutableEx .author(loggedUser) .build()).getCase(); ToDataSetOutcome outcome = body.toDataSet(); - itemCase = setDataWithExecute(itemCase, DashboardItemConstants.TASK_CONFIGURE, outcome.getDataSet()); + itemCase = setDataWithExecute(itemCase, DashboardItemConstants.TASK_CONFIGURE, splitToPhases(outcome.getDataSet())); return itemCase; } @@ -91,7 +86,7 @@ public Case getOrCreate(DashboardItemBody body) throws TransitionNotExecutableEx public Case update(Case itemCase, DashboardItemBody body) throws TransitionNotExecutableException { MenuItemUtils.sanitize(body.getId()); ToDataSetOutcome outcome = body.toDataSet(); - itemCase = setDataWithExecute(itemCase, DashboardItemConstants.TASK_CONFIGURE, outcome.getDataSet()); + itemCase = setDataWithExecute(itemCase, DashboardItemConstants.TASK_CONFIGURE, splitToPhases(outcome.getDataSet())); return itemCase; } @@ -113,8 +108,12 @@ protected Case setData(Case useCase, String transId, Map> dataSet) throws TransitionNotExecutableException { + protected Case setDataWithExecute(Case useCase, String transId, List>> dataSetBatches) throws TransitionNotExecutableException { AbstractUser loggedUser = userService.getLoggedOrSystem(); String taskId = MenuItemUtils.findTaskIdInCase(useCase, transId); Task task = taskService.findOne(taskId); @@ -122,11 +121,57 @@ protected Case setDataWithExecute(Case useCase, String transId, Map> batch : dataSetBatches) { + task = dataService.setData(task, ImportHelper.populateDataset((Map) batch)).getTask(); + } + return taskService.finishTask(TaskParams.with() + .task(task) + .user(loggedUser) + .build()).getCase(); + } catch (RuntimeException | TransitionNotExecutableException e) { + cancelQuietly(task, loggedUser); + throw e; + } + } + + private void cancelQuietly(Task task, AbstractUser user) { + try { + taskService.cancelTask(TaskParams.with() + .task(taskService.findOne(task.getStringId())) + .user(user) + .build()); + } catch (RuntimeException e) { + log.warn("Could not cancel task {} after a failed dashboard item update", task.getStringId(), e); + } + } + + /** + * Splits the data set into the steps a user takes in the configuration form. Set events of the + * dashboard_item process reset the icon and name whenever the target (is_internal, menu item), the + * inherit flags or external_icon change, so the values of the last step must be written after them. + * The order inside a phase matters too (is_internal clears external_url), hence ordered lists. + */ + private List>> splitToPhases(Map> dataSet) { + List> phases = List.of( + List.of(DashboardItemConstants.FIELD_ID, DashboardItemConstants.FIELD_IS_INTERNAL, + DashboardItemConstants.FIELD_EXTERNAL_URL), + List.of(DashboardItemConstants.FIELD_MENU_ITEM_LIST), + List.of(DashboardItemConstants.FIELD_INHERIT_ICON, DashboardItemConstants.FIELD_INHERIT_NAME), + List.of(DashboardItemConstants.FIELD_EXTERNAL_ICON)); + List>> batches = new ArrayList<>(); + Map> rest = new LinkedHashMap<>(dataSet); + for (List phase : phases) { + Map> batch = new LinkedHashMap<>(); + phase.forEach(fieldId -> { + if (rest.containsKey(fieldId)) { + batch.put(fieldId, rest.remove(fieldId)); + } + }); + batches.add(batch); + } + batches.add(rest); + return batches; } protected Case findCase(String processIdentifier, String query) { diff --git a/application-engine/src/main/resources/petriNets/engine-processes/dashboard_item.xml b/application-engine/src/main/resources/petriNets/engine-processes/dashboard_item.xml index 9b3d54b6d49..dcf62d5cf4f 100644 --- a/application-engine/src/main/resources/petriNets/engine-processes/dashboard_item.xml +++ b/application-engine/src/main/resources/petriNets/engine-processes/dashboard_item.xml @@ -83,8 +83,8 @@ if (item_id.value == "") { return } - def itemWithIdExists = findCaseElastic("processIdentifier:\"dashboard_item\" AND dataSet.item_id.textValue.keyword:\"${item_id.value}\" AND dataSet.is_active.booleanValue:true" as String) - if (itemWithIdExists != null) { + def itemsWithId = findCasesElastic("processIdentifier:\"dashboard_item\" AND dataSet.item_id.textValue.keyword:\"${item_id.value}\" AND dataSet.is_active.booleanValue:true" as String, org.springframework.data.domain.PageRequest.of(0, 100)) + if (itemsWithId.any { it.stringId != useCase.stringId }) { throw new IllegalArgumentException("Dashboard item with given ID already exists.") } @@ -717,8 +717,8 @@ if (item_id.value == null || item_id.value == "") { throw new IllegalArgumentException("Dashboard item ID cannot be empty.") } - def itemWithIdExists = findCaseElastic("processIdentifier:\"dashboard_item\" AND dataSet.item_id.textValue.keyword:\"${item_id.value}\" AND dataSet.is_active.booleanValue:true" as String) - if (itemWithIdExists != null) { + def itemsWithId = findCasesElastic("processIdentifier:\"dashboard_item\" AND dataSet.item_id.textValue.keyword:\"${item_id.value}\" AND dataSet.is_active.booleanValue:true" as String, org.springframework.data.domain.PageRequest.of(0, 100)) + if (itemsWithId.any { it.stringId != useCase.stringId }) { throw new IllegalArgumentException("Dashboard item with given ID already exists.") } if (is_internal.value) { diff --git a/nae-object-library/src/main/java/com/netgrif/application/engine/objects/workflow/domain/menu/dashboard/DashboardItemBody.java b/nae-object-library/src/main/java/com/netgrif/application/engine/objects/workflow/domain/menu/dashboard/DashboardItemBody.java index 23d2e66064c..062762db545 100644 --- a/nae-object-library/src/main/java/com/netgrif/application/engine/objects/workflow/domain/menu/dashboard/DashboardItemBody.java +++ b/nae-object-library/src/main/java/com/netgrif/application/engine/objects/workflow/domain/menu/dashboard/DashboardItemBody.java @@ -36,7 +36,9 @@ public DashboardItemBody(String id, String menuItems, String itemIcon, I18nStrin public ToDataSetOutcome toDataSet() { ToDataSetOutcome outcome = new ToDataSetOutcome(); - outcome.putDataSetEntry(DashboardItemConstants.FIELD_ID, FieldType.TEXT, this.id); + if (this.id != null) { + outcome.putDataSetEntry(DashboardItemConstants.FIELD_ID, FieldType.TEXT, this.id); + } outcome.putDataSetEntry(DashboardItemConstants.FIELD_MENU_ITEM_LIST, FieldType.ENUMERATION_MAP, this.menuItems); outcome.putDataSetEntry(DashboardItemConstants.FIELD_IS_INTERNAL, FieldType.BOOLEAN, this.isInternal); outcome.putDataSetEntry(DashboardItemConstants.FIELD_EXTERNAL_URL, FieldType.TEXT, this.externalUrl); From 256d48e95023d8a056c25a83bd9066bb9cc7c8de Mon Sep 17 00:00:00 2001 From: MartinKranec Date: Thu, 29 Oct 2026 13:18:55 +0100 Subject: [PATCH 2/2] [NAE-2502] - Dashboard items: updateDashboardItem fails on an existing tile, and itemIcon / name from the API are ignored - Implement rollback mechanism to restore original data when a step fails during batch updates. - Add `buildRollbackBatches` to capture field values for restoration. - Introduce `restoreQuietly` to reapply original data silently. - Update error handling to maintain item state integrity during failures. --- .../services/DashboardItemServiceImpl.java | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/application-engine/src/main/java/com/netgrif/application/engine/menu/services/DashboardItemServiceImpl.java b/application-engine/src/main/java/com/netgrif/application/engine/menu/services/DashboardItemServiceImpl.java index 4312d470bb7..d71b8465c3b 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/menu/services/DashboardItemServiceImpl.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/menu/services/DashboardItemServiceImpl.java @@ -110,12 +110,15 @@ protected Case setData(Case useCase, String transId, Map>> dataSetBatches) throws TransitionNotExecutableException { AbstractUser loggedUser = userService.getLoggedOrSystem(); String taskId = MenuItemUtils.findTaskIdInCase(useCase, transId); + List>> rollbackBatches = buildRollbackBatches(useCase, dataSetBatches); Task task = taskService.findOne(taskId); task = taskService.assignTask(TaskParams.with() .task(task) @@ -130,11 +133,49 @@ protected Case setDataWithExecute(Case useCase, String transId, List>> buildRollbackBatches(Case useCase, List>> dataSetBatches) { + Case current = workflowService.findOne(useCase.getStringId()); + Object currentId = current.getDataSet().get(DashboardItemConstants.FIELD_ID) == null ? null + : current.getDataSet().get(DashboardItemConstants.FIELD_ID).getValue(); + if (currentId == null || currentId.toString().isEmpty()) { + return List.of(); + } + List>> rollback = new ArrayList<>(); + for (Map> batch : dataSetBatches) { + Map> restore = new LinkedHashMap<>(); + batch.forEach((fieldId, entry) -> { + Map restoreEntry = new LinkedHashMap<>(); + restoreEntry.put("type", entry.get("type")); + restoreEntry.put("value", current.getDataSet().get(fieldId) == null ? null : current.getDataSet().get(fieldId).getValue()); + restore.put(fieldId, restoreEntry); + }); + rollback.add(restore); + } + return rollback; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private void restoreQuietly(Task task, List>> rollbackBatches) { + try { + Task assigned = taskService.findOne(task.getStringId()); + for (Map> batch : rollbackBatches) { + assigned = dataService.setData(assigned, ImportHelper.populateDataset((Map) batch)).getTask(); + } + } catch (RuntimeException e) { + log.warn("Could not restore the data of task {} after a failed dashboard item update", task.getStringId(), e); + } + } + private void cancelQuietly(Task task, AbstractUser user) { try { taskService.cancelTask(TaskParams.with()