From 7f59542828d77e327ccb1cfea46e40739d1830f2 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Mon, 18 May 2026 06:41:08 -0700 Subject: [PATCH] Add 'Delete Empty Tasks' admin action to ehr.tasks grid Adds a More Actions menu item, gated on EHRDataAdminPermission, that deletes the selected ehr.tasks rows after verifying none of them have referencing rows in study.studydata. Tasks with associated study dataset data are rejected with an error; data in other schemas is not checked. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ehr/buttons/DiscardEmptyTasksButton.java | 35 +++++ ehr/resources/web/ehr/studyButtons.js | 28 ++++ ehr/src/org/labkey/ehr/EHRController.java | 133 ++++++++++++++++++ ehr/src/org/labkey/ehr/EHRModule.java | 2 + 4 files changed, 198 insertions(+) create mode 100644 ehr/api-src/org/labkey/api/ehr/buttons/DiscardEmptyTasksButton.java diff --git a/ehr/api-src/org/labkey/api/ehr/buttons/DiscardEmptyTasksButton.java b/ehr/api-src/org/labkey/api/ehr/buttons/DiscardEmptyTasksButton.java new file mode 100644 index 000000000..9387dff36 --- /dev/null +++ b/ehr/api-src/org/labkey/api/ehr/buttons/DiscardEmptyTasksButton.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.api.ehr.buttons; + +import org.labkey.api.data.TableInfo; +import org.labkey.api.ehr.security.EHRDataAdminPermission; +import org.labkey.api.ldk.table.SimpleButtonConfigFactory; +import org.labkey.api.module.Module; + +public class DiscardEmptyTasksButton extends SimpleButtonConfigFactory +{ + public DiscardEmptyTasksButton(Module owner) + { + super(owner, "Delete Empty Tasks", "EHR.DatasetButtons.discardEmptyTasks(dataRegionName);"); + } + + @Override + public boolean isAvailable(TableInfo ti) + { + return super.isAvailable(ti) && ti.getUserSchema().getContainer().hasPermission(ti.getUserSchema().getUser(), EHRDataAdminPermission.class); + } +} diff --git a/ehr/resources/web/ehr/studyButtons.js b/ehr/resources/web/ehr/studyButtons.js index fc99b1ae8..adfc6fb37 100644 --- a/ehr/resources/web/ehr/studyButtons.js +++ b/ehr/resources/web/ehr/studyButtons.js @@ -266,6 +266,34 @@ EHR.DatasetButtons = new function () { }, this); }, + discardEmptyTasks: function (dataRegionName) { + var dataRegion = LABKEY.DataRegions[dataRegionName]; + var checked = dataRegion.getChecked(); + if (!checked || !checked.length) { + Ext4.Msg.alert('Error', 'No records selected'); + return; + } + + Ext4.Msg.confirm('Delete Empty Tasks', + 'Permanently delete the selected task(s)? This will fail if any selected task has related data in a study dataset. Note: Task related data in other schemas is not checked.', + function (val) { + if (val !== 'yes') return; + Ext4.Msg.wait('Deleting...'); + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('ehr', 'discardEmptyTasks', null, {taskIds: checked}), + method: 'POST', + success: function (response) { + Ext4.Msg.hide(); + var json = LABKEY.Utils.decode(response.responseText) || {}; + Ext4.Msg.alert('Success', 'Deleted ' + (json.deletedCount || 0) + ' task(s).'); + dataRegion.refresh(); + }, + failure: LDK.Utils.getErrorCallback(), + scope: this + }); + }, this); + }, + limitKinshipSelection: function (dataRegionName) { var dataRegion = LABKEY.DataRegions[dataRegionName]; diff --git a/ehr/src/org/labkey/ehr/EHRController.java b/ehr/src/org/labkey/ehr/EHRController.java index 7bf750c76..c02738ba3 100644 --- a/ehr/src/org/labkey/ehr/EHRController.java +++ b/ehr/src/org/labkey/ehr/EHRController.java @@ -49,6 +49,7 @@ import org.labkey.api.ehr.dataentry.DataEntryForm; import org.labkey.api.ehr.demographics.AnimalRecord; import org.labkey.api.ehr.history.HistoryRow; +import org.labkey.api.ehr.security.EHRDataAdminPermission; import org.labkey.api.ehr.security.EHRDataEntryPermission; import org.labkey.api.exp.api.ExperimentService; import org.labkey.api.gwt.client.AuditBehaviorType; @@ -60,6 +61,7 @@ import org.labkey.api.query.BatchValidationException; import org.labkey.api.query.DetailsURL; import org.labkey.api.query.FieldKey; +import org.labkey.api.query.InvalidKeyException; import org.labkey.api.query.QueryAction; import org.labkey.api.query.QueryForm; import org.labkey.api.query.QueryParseException; @@ -331,6 +333,137 @@ public ApiResponse execute(DiscardFormForm form, BindException errors) } } + public static class DiscardEmptyTasksForm + { + private String[] taskIds; + + public String[] getTaskIds() + { + return taskIds; + } + + public void setTaskIds(String[] taskIds) + { + this.taskIds = taskIds; + } + } + + @RequiresPermission(EHRDataAdminPermission.class) + public static class DiscardEmptyTasksAction extends MutatingApiAction + { + private List _selectedTaskIds; + private TableInfo _tasksTable; + + @Override + public void validateForm(DiscardEmptyTasksForm form, Errors errors) + { + super.validateForm(form, errors); + + if (form.getTaskIds() == null || form.getTaskIds().length == 0) + { + errors.reject(ERROR_MSG, "No tasks selected."); + return; + } + _selectedTaskIds = Arrays.asList(form.getTaskIds()); + + UserSchema ehrSchema = QueryService.get().getUserSchema(getUser(), getContainer(), EHRSchema.EHR_SCHEMANAME); + if (ehrSchema == null) + { + errors.reject(ERROR_MSG, "EHR schema is not available in this container."); + return; + } + + _tasksTable = ehrSchema.getTable(EHRSchema.TABLE_TASKS); + if (_tasksTable == null) + { + errors.reject(ERROR_MSG, "ehr.tasks table is not available in this container."); + return; + } + + Set nonEmpty = new LinkedHashSet<>(); + UserSchema studySchema = QueryService.get().getUserSchema(getUser(), getContainer(), "study"); + if (studySchema != null) + { + TableInfo studyData = studySchema.getTable("StudyData"); + if (studyData != null && studyData.getColumn("taskid") != null) + { + SimpleFilter filter = new SimpleFilter(FieldKey.fromString("taskid"), _selectedTaskIds, CompareType.IN); + String[] ids = new TableSelector(studyData, Collections.singleton("taskid"), filter, null).getArray(String.class); + if (ids != null) + { + for (String id : ids) + { + if (id != null) + nonEmpty.add(id); + } + } + } + } + + if (!nonEmpty.isEmpty()) + { + errors.reject(ERROR_MSG, "Cannot delete: " + nonEmpty.size() + " of the selected task(s) have associated dataset records. Task ID(s): " + String.join(", ", nonEmpty)); + } + } + + @Override + public ApiResponse execute(DiscardEmptyTasksForm form, BindException errors) + { + int deleted; + try (DbScope.Transaction transaction = ExperimentService.get().ensureTransaction()) + { + deleted = deleteRowsByTaskIds(_tasksTable, _selectedTaskIds); + transaction.commit(); + } + catch (SQLException e) + { + throw new RuntimeSQLException(e); + } + + Map resultProperties = new HashMap<>(); + resultProperties.put("success", true); + resultProperties.put("deletedCount", deleted); + return new ApiSimpleResponse(resultProperties); + } + + private int deleteRowsByTaskIds(TableInfo ti, List taskIds) throws SQLException + { + QueryUpdateService qus = ti.getUpdateService(); + if (qus == null) + return 0; + + int total = 0; + final int chunkSize = 1000; + for (int start = 0; start < taskIds.size(); start += chunkSize) + { + List chunk = taskIds.subList(start, Math.min(start + chunkSize, taskIds.size())); + SimpleFilter filter = new SimpleFilter(FieldKey.fromString("taskid"), chunk, CompareType.IN); + String[] pkColumns = ti.getPkColumnNames().toArray(new String[0]); + Set selectCols = new LinkedHashSet<>(Arrays.asList(pkColumns)); + selectCols.add("taskid"); + + Map[] rows = new TableSelector(ti, selectCols, filter, null).getMapArray(); + if (rows == null || rows.length == 0) + continue; + + List> keys = new ArrayList<>(rows.length); + for (Map row : rows) + keys.add(new HashMap<>(row)); + + try + { + qus.deleteRows(getUser(), getContainer(), keys, null, new HashMap<>()); + } + catch (InvalidKeyException | QueryUpdateServiceException | BatchValidationException e) + { + throw new RuntimeException(e); + } + total += rows.length; + } + return total; + } + } + public static class EHRQueryForm extends QueryForm { private boolean _showImport = false; diff --git a/ehr/src/org/labkey/ehr/EHRModule.java b/ehr/src/org/labkey/ehr/EHRModule.java index 953be0a11..5ee3f873d 100644 --- a/ehr/src/org/labkey/ehr/EHRModule.java +++ b/ehr/src/org/labkey/ehr/EHRModule.java @@ -25,6 +25,7 @@ import org.labkey.api.data.UpgradeCode; import org.labkey.api.ehr.EHRDemographicsService; import org.labkey.api.ehr.EHRService; +import org.labkey.api.ehr.buttons.DiscardEmptyTasksButton; import org.labkey.api.ehr.buttons.EHRShowEditUIButton; import org.labkey.api.ehr.buttons.MarkCompletedButton; import org.labkey.api.ehr.demographics.ActiveAssignmentsDemographicsProvider; @@ -249,6 +250,7 @@ public void moduleStartupComplete(ServletContext servletContext) EHRService.get().registerMoreActionsButton(new CompareWeightsButton(this), "study", "weight"); EHRService.get().registerMoreActionsButton(new TaskAssignButton(this), "ehr", "my_tasks"); EHRService.get().registerMoreActionsButton(new TaskAssignButton(this), "ehr", "tasks"); + EHRService.get().registerMoreActionsButton(new DiscardEmptyTasksButton(this), "ehr", "tasks"); EHRService.get().registerMoreActionsButton(new MarkCompletedButton(this, "study", "treatment_order", "Set End Date"), "study", "treatment_order"); EHRService.get().registerMoreActionsButton(new MarkCompletedButton(this, "study", "problem", "End Problem(s)", true), "study", "problem"); EHRService.get().registerMoreActionsButton(new MarkCompletedButton(this, "study", "feeding"), "study", "feeding");