Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 0 additions & 46 deletions api/src/org/labkey/api/query/QueryView.java
Original file line number Diff line number Diff line change
Expand Up @@ -1050,52 +1050,6 @@ public ActionButton createDeleteButton(boolean showConfirmation)
return null;
}

Comment on lines 1050 to 1052
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This removes the public QueryView.createDeleteAllRowsButton() method from the API module. Even though there are no in-repo references, this is a binary/source breaking change for external modules that compile against the LabKey API. Consider keeping the method (deprecated) and altering its behavior/visibility, or providing a compatible replacement path so downstream modules don’t break on upgrade.

Copilot uses AI. Check for mistakes.
public ActionButton createDeleteAllRowsButton(String tableNoun)
{
ActionButton deleteAllRows = new ActionButton("Delete All Rows");
deleteAllRows.setDisplayPermission(AdminPermission.class);
deleteAllRows.setActionType(ActionButton.Action.SCRIPT);
deleteAllRows.setScript(
"LABKEY.requiresExt4Sandbox(function() {" +
"Ext4.Msg.confirm('Confirm Deletion', 'Are you sure you wish to delete all rows in this " + tableNoun + "? This action cannot be undone and will result in an empty " + tableNoun + ".', function(button){" +
"if (button == 'yes'){" +
"var waitMask = Ext4.Msg.wait('Deleting Rows...', 'Delete Rows'); " +
"Ext4.Ajax.request({ " +
"url : LABKEY.ActionURL.buildURL('query', 'truncateTable'), " +
"method : 'POST', " +
"success: function(response) " +
"{" +
"waitMask.close(); " +
"var data = Ext4.JSON.decode(response.responseText); " +
"Ext4.Msg.show({ " +
"title : 'Success', " +
"buttons : Ext4.MessageBox.OK, " +
"msg : data.deletedRows + ' rows deleted', " +
"fn: function(btn) " +
"{ " +
"if(btn == 'ok') " +
"{ " +
"window.location.reload(); " +
"} " +
"} " +
"})" +
"}, " +
"failure : function(response, opts) " +
"{ " +
"waitMask.close(); " +
"Ext4.getBody().unmask(); " +
"LABKEY.Utils.displayAjaxErrorResponse(response, opts); " +
"}, " +
"jsonData : {schemaName : " + PageFlowUtil.jsString(getQueryDef().getSchema().getName()) + ", queryName : " + PageFlowUtil.jsString(getQueryDef().getName()) + "}, " +
"scope : this " +
"});" +
"}" +
"});" +
"});"
);
return deleteAllRows;
}

public ActionButton createInsertMenuButton()
{
return createInsertMenuButton(null, null);
Expand Down
104 changes: 102 additions & 2 deletions list/src/org/labkey/list/controllers/ListController.java
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
import org.labkey.api.data.ContainerFilter;
import org.labkey.api.data.ContainerManager;
import org.labkey.api.data.DataRegionSelection;
import org.labkey.api.data.DbScope;
import org.labkey.api.data.SQLFragment;
import org.labkey.api.data.SimpleFilter;
import org.labkey.api.data.TableInfo;
Expand Down Expand Up @@ -116,6 +117,7 @@
import org.labkey.list.model.ListDomainKindProperties;
import org.labkey.list.model.ListManager;
import org.labkey.list.model.ListManagerSchema;
import org.labkey.list.model.ListSchema;
import org.labkey.list.model.ListWriter;
import org.labkey.list.view.ListDefinitionForm;
import org.labkey.list.view.ListItemAttachmentParent;
Expand Down Expand Up @@ -425,6 +427,103 @@ public List<Pair<Integer, Container>> getListContainerMap()
}
}

@RequiresPermission(AdminPermission.class)
public static class TruncateListDataAction extends ConfirmAction<ListDeletionForm>
{
Comment on lines +430 to +432
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description still contains the template placeholders for rationale and change list. Please update the PR description with the actual rationale/behavioral change summary (and any manual testing notes) before merging so reviewers/release notes have accurate context.

Copilot uses AI. Check for mistakes.
private boolean canTruncate(Container listContainer, int listId)
{
ListDef listDef = ListManager.get().getList(listContainer, listId);
ListDefinitionImpl list = ListDefinitionImpl.of(listDef);

if (list == null || !list.getAllowDelete())
return false;

return list.getContainer().hasPermission(getUser(), AdminPermission.class);
}

@Override
public String getConfirmText()
{
return "Confirm Delete All Data";
}

@Override
public void validateCommand(ListDeletionForm form, Errors errors)
{
Container currentContainer = getContainer();
List<String> errorMessages = new ArrayList<>();
Collection<String> listIDs;
if (form.getListIds() != null)
listIDs = form.getListIds();
else
listIDs = DataRegionSelection.getSelected(form.getViewContext(), true);

for (Pair<Integer, Container> pair : getListIdContainerPairs(listIDs, currentContainer, errorMessages))
{
var listId = pair.first;
var listContainer = pair.second;

if (canTruncate(listContainer, listId))
{
form.getListContainerMap().add(pair);
}
else
errorMessages.add(String.format("You do not have permission to delete data for list %s in container %s", listId, listContainer.getName()));
}

if (!errorMessages.isEmpty())
errors.reject(ERROR_MSG, StringUtils.join(errorMessages, "\n"));

if (form.getListContainerMap().isEmpty())
errors.reject(ERROR_MSG, "You must specify a list or lists to delete data from.");
}

@Override
public ModelAndView getConfirmView(ListDeletionForm form, BindException errors)
{
if (getPageConfig().getTitle() == null)
setTitle("Confirm Delete All Data");
return new JspView<>("/org/labkey/list/view/truncateListData.jsp", form, errors);
}

@Override
public boolean handlePost(ListDeletionForm form, BindException errors)
{
Container containerDataToDelete = getContainer();
try (DbScope.Transaction transaction = ListSchema.getInstance().getSchema().getScope().ensureTransaction())
{
for (Pair<Integer, Container> pair : form.getListContainerMap())
{
Container listDefContainer = pair.second;
ListDefinition listDef = ListService.get().getList(listDefContainer, pair.first);
if (null != listDef)
{
try
{
TableInfo table = listDef.getTable(getUser(), listDefContainer);
if (table != null && table.getUpdateService() != null)
table.getUpdateService().truncateRows(getUser(), containerDataToDelete, null, null);
}
catch (Exception e)
{
errors.reject(ERROR_MSG, "Error deleting data from list '" + listDef.getName() + "': " + e.getMessage());
return false;
}
}
}

transaction.commit();
}

return !errors.hasErrors();
}

@Override @NotNull
public URLHelper getSuccessURL(ListDeletionForm form)
{
return form.getReturnUrlHelper(getBeginURL(getContainer()));
}
}

@RequiresPermission(ReadPermission.class)
public class GridAction extends SimpleViewAction<ListQueryForm>
Expand Down Expand Up @@ -958,10 +1057,11 @@ public Pair<AttachmentParent, String> getAttachment(ListAttachmentForm form)
if (listDef == null)
throw new NotFoundException("List does not exist in this container");

if (!listDef.hasListItemForEntityId(form.getEntityId(), getUser()))
Container dataContainer = listDef.getListItemContainerForDownload(form.getEntityId(), getUser(), ReadPermission.class);
if (dataContainer == null)
throw new NotFoundException("List does not have an item for the entityid");

AttachmentParent parent = new ListItemAttachmentParent(form.getEntityId(), getContainer());
AttachmentParent parent = new ListItemAttachmentParent(form.getEntityId(), dataContainer);

return new Pair<>(parent, form.getName());
}
Expand Down
38 changes: 29 additions & 9 deletions list/src/org/labkey/list/model/ListDefinitionImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
import org.labkey.api.reader.DataLoader;
import org.labkey.api.reader.MapLoader;
import org.labkey.api.security.User;
import org.labkey.api.security.permissions.Permission;
import org.labkey.api.util.ReentrantLockWithName;
import org.labkey.api.util.URLHelper;
import org.labkey.api.view.ActionURL;
Expand Down Expand Up @@ -521,19 +522,38 @@ private ListItem getListItem(SimpleFilter filter, User user, Container c)
return impl;
}

public boolean hasListItemForEntityId(String entityId, User user)
public @Nullable Container getListItemContainerForDownload(String entityId, User user, Class<? extends Permission> permissionClass)
{
return hasListItem(new SimpleFilter(FieldKey.fromParts("EntityId"), entityId), user, getContainer());
}

private boolean hasListItem(SimpleFilter filter, User user, Container c)
{
TableInfo tbl = getTable(user, c);
Container c = getContainer();
SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("EntityId"), entityId);
// Use a relax CF to find the list items, permission will be validated later
ContainerFilter cf = ContainerFilter.Type.AllInProjectPlusShared.create(c, user);
TableInfo tbl = getTable(user, c, cf);

if (null == tbl)
return false;
return null;

Map<String, Object> row = null;

try
{
row = new TableSelector(tbl, filter, null).getMap();
}
catch (IllegalStateException e)
{
// More than one row matches the specified EntityId; log for diagnosis and return null as before
LOG.warn("Multiple list items match EntityId '{}' when resolving download container. List: '{}', Container: '{}'. Returning null.",
entityId, getName(), getContainer().getPath(), e);
}

if (row == null)
return null;

Container dataContainer = row.get("Container") != null ? ContainerManager.getForId(row.get("Container").toString()) : null;
if (dataContainer != null && dataContainer.hasPermission(user, permissionClass))
return dataContainer;

return new TableSelector(tbl, filter, null).exists();
return null;
}

@Override
Expand Down
38 changes: 32 additions & 6 deletions list/src/org/labkey/list/model/ListManagerSchema.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import org.labkey.api.data.ActionButton;
import org.labkey.api.data.ButtonBar;
import org.labkey.api.data.Container;
import org.labkey.api.data.DataRegion;
import org.labkey.api.data.MenuButton;
import org.labkey.api.data.ContainerFilter;
import org.labkey.api.data.ContainerManager;
import org.labkey.api.data.DisplayColumn;
Expand All @@ -38,6 +40,7 @@
import org.labkey.api.query.QueryView;
import org.labkey.api.query.UserSchema;
import org.labkey.api.security.User;
import org.labkey.api.security.permissions.AdminPermission;
import org.labkey.api.util.ButtonBuilder;
import org.labkey.api.util.LinkBuilder;
import org.labkey.api.view.ActionURL;
Expand Down Expand Up @@ -162,14 +165,37 @@ private ButtonBuilder.Button createImportListArchiveButton()
@Override
public ActionButton createDeleteButton()
{
if (!getContainer().hasPermission(getUser(), DesignListPermission.class))
return null;

if (!getContainer().hasPermission(getUser(), AdminPermission.class))
{
ActionURL urlDelete = new ActionURL(ListController.DeleteListDefinitionAction.class, getContainer());
urlDelete.addReturnUrl(getReturnUrl());
ActionButton btnDelete = new ActionButton(urlDelete, "Delete");
btnDelete.setIconCls("trash");
btnDelete.setActionType(ActionButton.Action.GET);
btnDelete.setRequiresSelection(true);
return btnDelete;
}

MenuButton menuDelete = new MenuButton("Delete");
menuDelete.setIconCls("trash");
menuDelete.setDisplayPermission(DesignListPermission.class);
menuDelete.setRequiresSelection(true);

ActionURL urlDelete = new ActionURL(ListController.DeleteListDefinitionAction.class, getContainer());
urlDelete.addReturnUrl(getReturnUrl());
ActionButton btnDelete = new ActionButton(urlDelete, "Delete");
btnDelete.setIconCls("trash");
btnDelete.setActionType(ActionButton.Action.GET);
btnDelete.setDisplayPermission(DesignListPermission.class);
btnDelete.setRequiresSelection(true);
return btnDelete;
menuDelete.addMenuItem("Delete List", "if (verifySelected(" + DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".form, \"" +
urlDelete.getLocalURIString() + "\", \"GET\", \"rows\")) " + DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".form.submit()");


ActionURL urlTruncate = new ActionURL(ListController.TruncateListDataAction.class, getContainer());
urlTruncate.addReturnUrl(getReturnUrl());
menuDelete.addMenuItem("Delete All Data from List", "if (verifySelected(" + DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".form, \"" +
urlTruncate.getLocalURIString() + "\", \"GET\", \"rows\")) " + DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".form.submit()");

return menuDelete;
}

private ActionButton createExportArchiveButton()
Expand Down
2 changes: 1 addition & 1 deletion list/src/org/labkey/list/model/ListQueryUpdateService.java
Original file line number Diff line number Diff line change
Expand Up @@ -767,7 +767,7 @@ public void deleteRelatedListData(final User user, final Container container)
ListManager.get().deleteIndexedList(_list);

// Delete attachments and discussions associated with a list in batches of 1,000
new TableSelector(getDbTable(), Collections.singleton("entityId")).forEachBatch(String.class, 1000, new ForEachBatchBlock<>()
new TableSelector(getDbTable(), Collections.singleton("entityId"), SimpleFilter.createContainerFilter(container), null).forEachBatch(String.class, 1000, new ForEachBatchBlock<>()
{
@Override
public boolean accept(String entityId)
Expand Down
2 changes: 0 additions & 2 deletions list/src/org/labkey/list/view/ListQueryView.java
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,6 @@ protected void populateButtonBar(DataView view, ButtonBar bar)
ActionButton btnUpload = new ActionButton("Design", designURL);
bar.add(btnUpload);
}
if (canDelete())
bar.add(super.createDeleteAllRowsButton("list"));
}

public ListDefinition getList()
Expand Down
70 changes: 70 additions & 0 deletions list/src/org/labkey/list/view/truncateListData.jsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<%
/*
* Copyright (c) 2009-2016 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.
*/
%>
<%@ page import="org.labkey.api.data.Container" %>
<%@ page import="org.labkey.api.data.TableInfo" %>
<%@ page import="org.labkey.api.data.TableSelector" %>
<%@ page import="org.labkey.api.exp.list.ListDefinition" %>
<%@ page import="org.labkey.api.exp.list.ListService" %>
<%@ page import="org.labkey.list.controllers.ListController" %>
<%@ page import="java.util.ArrayList" %>
<%@ page import="java.util.HashMap" %>
<%@ page import="java.util.List" %>
<%@ page import="java.util.Map" %>
<%@ page import="java.util.Objects" %>
<%@ page import="org.labkey.list.model.ListQuerySchema" %>
<%@ page import="org.labkey.api.security.User" %>
<%@ page extends="org.labkey.api.jsp.FormPage" %>
<%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib"%>
<%
ListController.ListDeletionForm form = (ListController.ListDeletionForm)getModelBean();
Map<Container, List<ListDefinition>> definitions = new HashMap<>();
Container currentContainer = form.getContainer();
String currentPath = currentContainer.getPath();
User user = form.getUser();
ListQuerySchema listQuerySchema = new ListQuerySchema(user, currentContainer);

form.getListContainerMap()
.stream()
.map(pair -> ListService.get().getList(pair.second, pair.first))
.filter(Objects::nonNull)
.forEach(listDef -> {
var container = listDef.getContainer();
if (!definitions.containsKey(container))
definitions.put(container, new ArrayList<>());
definitions.get(container).add(listDef);
});
%>
<labkey:errors></labkey:errors>
<p>Are you sure you want to delete all data in <%= h(currentPath) %> from the following Lists? This action cannot be undone and will result in an empty list.</p>
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The confirmation text says truncating will "result in an empty list", but this action deletes rows only from the current folder/container (not necessarily all rows across the project/shared list). Suggest rewording to explicitly state it will delete all rows for this list in the current folder/container path shown.

Suggested change
<p>Are you sure you want to delete all data in <%= h(currentPath) %> from the following Lists? This action cannot be undone and will result in an empty list.</p>
<p>Are you sure you want to delete all data in <%= h(currentPath) %> from the following Lists? This action cannot be undone and will delete all rows for these lists in the folder/container path shown, but will not affect rows in other folders/containers.</p>

Copilot uses AI. Check for mistakes.

<% for (var entry : definitions.entrySet()) { %>
<div>
List definitions defined in <%= h(entry.getKey().getPath()) %>:
<ul>
<% for (var listDef : entry.getValue()) {
TableInfo table = listQuerySchema.getTable(listDef.getName());
long count = (table != null) ? new TableSelector(table).getRowCount() : 0;
%>
Comment on lines +60 to +62
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Row counts are computed via listQuerySchema.getTable(listDef.getName()), which resolves the list by name in the current container. If multiple selected lists share the same name but are defined in different containers, the displayed count can be for the wrong list. Consider deriving the TableInfo from the selected ListDefinition (e.g., use the list’s definition container to resolve the correct list, and apply an explicit container filter for the current container when counting rows).

Copilot uses AI. Check for mistakes.
<li>
<%= simpleLink(listDef.getName(), listDef.urlFor(ListController.GridAction.class, currentContainer)) %>
&mdash; <%= count %> row<%= h(count != 1 ? "s" : "") %> in <%= h(currentPath) %>
</li>
<% } %>
</ul>
</div>
<% } %>
Loading
Loading