diff --git a/api/src/org/labkey/api/data/DataRegion.java b/api/src/org/labkey/api/data/DataRegion.java index 3aa785ea704..e5bac1b8929 100644 --- a/api/src/org/labkey/api/data/DataRegion.java +++ b/api/src/org/labkey/api/data/DataRegion.java @@ -1558,7 +1558,8 @@ private HtmlWriter renderNavTree(HtmlWriter out) final String jsObject = getJavaScriptObjectReference(); NavTree navtree = new NavTree(); - NavTree selectAll = new NavTree("Select All"); + NavTree selectAll = new NavTree("Select All Rows"); + selectAll.setId(getDomId() + "-navtree-select-all"); selectAll.setScript(jsObject + ".selectAll();"); navtree.addChild(selectAll); diff --git a/api/src/org/labkey/api/data/DataRegionSelection.java b/api/src/org/labkey/api/data/DataRegionSelection.java index ec2a3c5e1ca..acdf4b5c976 100644 --- a/api/src/org/labkey/api/data/DataRegionSelection.java +++ b/api/src/org/labkey/api/data/DataRegionSelection.java @@ -27,6 +27,7 @@ import org.labkey.api.query.QueryForm; import org.labkey.api.query.QueryService; import org.labkey.api.query.QueryView; +import org.labkey.api.util.Formats; import org.labkey.api.util.Pair; import org.labkey.api.util.SessionHelper; import org.labkey.api.view.ActionURL; @@ -45,7 +46,6 @@ import java.util.Collection; import java.util.Collections; import java.util.LinkedHashSet; -import java.util.LinkedList; import java.util.List; import java.util.Set; @@ -54,8 +54,6 @@ * Uses a synchronized Set. As per documentation on {@link Collections#synchronizedSet(Set)}, callers * should do their own synchronization on the set itself if they are operating on it one element at a time * and want to have a consistent view. This allows for the backing set to be a {@link LinkedHashSet}. - * User: kevink - * Date: Jan 3, 2008 */ public class DataRegionSelection { @@ -63,6 +61,9 @@ public class DataRegionSelection public static final String SEPARATOR = "$"; public static final String DATA_REGION_SELECTION_KEY = "dataRegionSelectionKey"; + // Issue 53997: Establish a maximum size for query selections + public static final int MAX_QUERY_SELECTION_SIZE = 100_000; + // set/updated using query-setSnapshotSelection // can be used to hold an arbitrary set of selections in session // example usage: set a filtered set of selected values in session @@ -207,11 +208,9 @@ public static String getSelectionKeyFromRequest(ViewContext context) values = context.getRequest().getParameterValues(DataRegion.SELECT_CHECKBOX_NAME); if (null != values && values.length == 1 && values[0].contains("\t")) values = StringUtils.split(values[0],'\t'); - List parameterSelected = values == null ? new ArrayList<>() : Arrays.asList(values); - Set result = new LinkedHashSet<>(parameterSelected); + Set result = values == null ? new LinkedHashSet<>() : new LinkedHashSet<>(Arrays.asList(values)); Set sessionSelected = getSet(context, key, false); - //noinspection SynchronizationOnLocalVariableOrMethodParameter synchronized (sessionSelected) { result.addAll(sessionSelected); @@ -268,14 +267,73 @@ public static int setSelected(ViewContext context, String key, Collection selection, boolean checked, boolean useSnapshot) { + return setSelected(context, key, selection, checked, useSnapshot, false); + } + + private static int setSelected( + ViewContext context, + String key, + Collection selection, + boolean checked, + boolean useSnapshot, + boolean replaceSelection + ) + { + if (checked && selection.size() > MAX_QUERY_SELECTION_SIZE) + throw new BadRequestException(selectionTooLargeMessage(selection.size())); + Set selectedValues = getSet(context, key, true, useSnapshot); - if (checked) - selectedValues.addAll(selection); - else - selectedValues.removeAll(selection); + synchronized (selectedValues) + { + if (checked) + { + if (replaceSelection) + { + selectedValues.clear(); + } + else if (selectedValues.size() + selection.size() > MAX_QUERY_SELECTION_SIZE) + { + // Verify that adding these selections will not result in a set that is too large + // Do not modify the actual selected values yet + int current = selectedValues.size(); + int distinctAdds = 0; + + for (String id : selection) + { + if (!selectedValues.contains(id)) + distinctAdds++; + } + + int prospective = current + distinctAdds; + if (prospective > MAX_QUERY_SELECTION_SIZE) + throw new BadRequestException(selectionTooLargeMessage(prospective)); + } + + selectedValues.addAll(selection); + } + else + selectedValues.removeAll(selection); + } + return selectedValues.size(); } + public static int setSelectedFromForm(QueryForm form) + { + var view = getQueryView(form); + var viewContext = view.getViewContext(); + var selection = getSet(viewContext, form.getQuerySettings().getSelectionKey(), true); + var items = getSelectedItems(view, selection); + + return setSelected(viewContext, form.getQuerySettings().getSelectionKey(), items, false); + } + + private static String selectionTooLargeMessage(long size) + { + return String.format("Too many selected items: %s. Maximum number of selected items allowed is %s.", + Formats.commaf0.format(size), Formats.commaf0.format(MAX_QUERY_SELECTION_SIZE)); + } + /** * Clear any session attributes that match the given container path, as the prefix, and the selection key, as the suffix */ @@ -339,23 +397,21 @@ public static void clearAll(ViewContext context) * Gets the ids of the selected items for all items in the given query form's view. That is, * not just the items on the current page, but all selected items corresponding to the view's filters. */ - public static List getSelected(QueryForm form, boolean clearSelected) throws IOException + public static Set getSelected(QueryForm form, boolean clearSelected) throws IOException { - List items; var view = getQueryView(form); - var selection = getSet(view.getViewContext(), form.getQuerySettings().getSelectionKey(), true); - items = getSelectedItems(view, selection); + var items = getSelectedItems(view, selection); if (clearSelected && !selection.isEmpty()) { - //noinspection SynchronizationOnLocalVariableOrMethodParameter synchronized (selection) { items.forEach(selection::remove); } } - return Collections.unmodifiableList(items); + + return Collections.unmodifiableSet(items); } private static Pair getDataRegionContext(QueryView view) @@ -399,7 +455,7 @@ private static Pair getDataRegionContext(QueryView vi return schema.createView(form, null); } - public static List getValidatedIds(@NotNull List selection, QueryForm form) throws IOException + public static Set getValidatedIds(@NotNull Collection selection, QueryForm form) { return getSelectedItems(getQueryView(form), selection); } @@ -446,8 +502,8 @@ public static int setSelectionForAll(QueryView view, String key, boolean checked try (Timing ignored = MiniProfiler.step("selectAll"); ResultSet rs = rgn.getResults(rc)) { - var selection = createSelectionList(rc, rgn, rs, null); - return setSelected(view.getViewContext(), key, selection, checked); + var selection = createSelectionSet(rc, rgn, rs, null); + return setSelected(view.getViewContext(), key, selection, checked, false, true); } catch (SQLException e) { @@ -460,13 +516,13 @@ public static int setSelectionForAll(QueryView view, String key, boolean checked * @param view the view from which to retrieve the data region context and session variable * @param selectedValues optionally (nullable) specify a collection of selected values that will be matched * against when selecting items. If null, then all items will be returned. - * @return list of items from the result set that are in the selected session, or an empty list if none. + * @return Set of items from the result set that are in the selected session, or an empty list if none. */ - private static List getSelectedItems(QueryView view, @NotNull Collection selectedValues) throws IOException + private static Set getSelectedItems(QueryView view, @NotNull Collection selectedValues) { // Issue 48657: no need to query the region result set if we have no selectedValues if (selectedValues.isEmpty()) - return new LinkedList<>(); + return new LinkedHashSet<>(); var dataRegionContext = getDataRegionContext(view); var rgn = dataRegionContext.first; @@ -486,7 +542,7 @@ private static List getSelectedItems(QueryView view, @NotNull Collection //noinspection SynchronizationOnLocalVariableOrMethodParameter synchronized (selectedValues) { - return createSelectionList(ctx, rgn, rs, selectedValues); + return createSelectionSet(ctx, rgn, rs, selectedValues); } } catch (SQLException e) @@ -495,14 +551,14 @@ private static List getSelectedItems(QueryView view, @NotNull Collection } } - private static List createSelectionList( - RenderContext ctx, - DataRegion rgn, - ResultSet rs, - @Nullable Collection selectedValues + private static Set createSelectionSet( + RenderContext ctx, + DataRegion rgn, + ResultSet rs, + @Nullable Collection selectedValues ) throws SQLException { - List selected = new LinkedList<>(); + Set selected = new LinkedHashSet<>(); if (rs != null) { @@ -516,7 +572,11 @@ private static List createSelectionList( { var value = rgn.getRecordSelectorValue(ctx); if (selectedValues == null || selectedValues.contains(value)) + { selected.add(value); + if (selected.size() == MAX_QUERY_SELECTION_SIZE) + break; + } } } } diff --git a/api/src/org/labkey/api/query/QueryService.java b/api/src/org/labkey/api/query/QueryService.java index 844b9cb3db6..bbccad189f6 100644 --- a/api/src/org/labkey/api/query/QueryService.java +++ b/api/src/org/labkey/api/query/QueryService.java @@ -56,6 +56,7 @@ public interface QueryService String EXPERIMENTAL_LAST_MODIFIED = "queryMetadataLastModified"; String EXPERIMENTAL_PRODUCT_ALL_FOLDER_LOOKUPS = "queryProductAllFolderLookups"; String EXPERIMENTAL_PRODUCT_PROJECT_DATA_LISTING_SCOPED = "queryProductProjectDataListingScoped"; + String MAX_QUERY_SELECTION = "maxQuerySelection"; String PRODUCT_FOLDERS_ENABLED = "isProductFoldersEnabled"; String PRODUCT_FOLDERS_EXIST = "hasProductFolders"; String USE_ROW_BY_ROW_UPDATE = "useLegacyUpdateRows"; diff --git a/api/src/org/labkey/api/view/NavTree.java b/api/src/org/labkey/api/view/NavTree.java index aa39c319568..4faf50532ff 100644 --- a/api/src/org/labkey/api/view/NavTree.java +++ b/api/src/org/labkey/api/view/NavTree.java @@ -755,8 +755,13 @@ public LinkBuilder toLinkBuilder(@Nullable String cls) } } + String id = getId(); + if (id == null) + id = config.makeId("popupMenuView"); + id = id.replaceAll(" ", ""); + LinkBuilder builder = LinkBuilder.simpleLink(html) - .id(config.makeId("popupMenuView")) + .id(id) .target(getTarget()) .title(getDescription()) .tabindex(0) diff --git a/api/webapp/clientapi/dom/DataRegion.js b/api/webapp/clientapi/dom/DataRegion.js index 28899dd1ac4..2310bafd0c5 100644 --- a/api/webapp/clientapi/dom/DataRegion.js +++ b/api/webapp/clientapi/dom/DataRegion.js @@ -16,6 +16,7 @@ if (!LABKEY.DataRegions) { var ALL_ROWS_MAX = 5_000; var CUSTOM_VIEW_PANELID = '~~customizeView~~'; var DEFAULT_TIMEOUT = 30_000; + const MAX_SELECTION_SIZE = LABKEY.moduleContext.query?.maxQuerySelection ?? 100_000; var PARAM_PREFIX = '.param.'; var SORT_ASC = '+'; var SORT_DESC = '-'; @@ -851,6 +852,11 @@ if (!LABKEY.DataRegions) { if (!_selDocClick) { _selDocClick = $(document).on('click', _onDocumentClick); } + + // Issue 53997: Establish a maximum size for query selections + if (_isShowSelectAll(this)) { + _getNavTreeSelectAllSelector(this).html(_getSelectAllText(this)); + } }; var _selClickLock; // lock to prevent removing a row highlight that was just applied @@ -910,10 +916,10 @@ if (!LABKEY.DataRegions) { LABKEY.DataRegion.clearSelected(config); } - if (this.showRows == 'selected') { + if (this.showRows === 'selected') { _removeParameters(this, [SHOW_ROWS_PREFIX]); } - else if (this.showRows == 'unselected') { + else if (this.showRows === 'unselected') { // keep "SHOW_ROWS_PREFIX=unselected" parameter window.location.reload(true); } @@ -1035,16 +1041,31 @@ if (!LABKEY.DataRegions) { LABKEY.DataRegion.selectAll(config); - if (this.showRows === "selected") { + if (this.showRows === 'selected') { // keep "SHOW_ROWS_PREFIX=selected" parameter window.location.reload(true); } - else if (this.showRows === "unselected") { + else if (this.showRows === 'unselected') { _removeParameters(this, [SHOW_ROWS_PREFIX]); } - else { + else if (this.totalRows <= MAX_SELECTION_SIZE) { _toggleAllRows(this, true); } + else { + // The number of selected rows exceeds MAX_SELECTION_SIZE, so here we determine + // which rows should be checked given which page (offset) we're on. + const lastRowIdx = this.offset + this.rowCount; + if (lastRowIdx < MAX_SELECTION_SIZE) { + // On a page where ALL rows are within the first MAX_SELECTION_SIZE rows, + _toggleAllRows(this, true); + } else if (this.offset < MAX_SELECTION_SIZE && MAX_SELECTION_SIZE < lastRowIdx) { + // On a page where SOME rows are within the first MAX_SELECTION_SIZE rows. + _checkRows(this, MAX_SELECTION_SIZE - this.offset); + } else { + // On a page where NONE rows are within the first MAX_SELECTION_SIZE rows. + _toggleAllRows(this, false); + } + } } }; @@ -1079,15 +1100,15 @@ if (!LABKEY.DataRegions) { var msg; if (me.totalRows) { if (count == me.totalRows) { - msg = 'All ' + this.totalRows + ' rows selected.'; + msg = 'All ' + this.totalRows.toLocaleString() + ' rows selected.'; } else { - msg = 'Selected ' + count + ' of ' + this.totalRows + ' rows.'; + msg = 'Selected ' + count.toLocaleString() + ' of ' + this.totalRows.toLocaleString() + ' rows.'; } } else { // totalRows isn't available when showing all rows. - msg = 'Selected ' + count + ' rows.'; + msg = 'Selected ' + count.toLocaleString() + ' rows.'; } _showSelectMessage(me, msg); } @@ -1117,7 +1138,7 @@ if (!LABKEY.DataRegions) { }; /** - * Add or remove items from the selection associated with the this DataRegion. + * Add or remove items from the selection associated with this DataRegion. * * @param config A configuration object with the following properties: * @param {Array} config.ids Array of primary key ids for each row to select/unselect. @@ -1157,7 +1178,11 @@ if (!LABKEY.DataRegions) { config.failure = failure; } else { - config.failure = function() { me.addMessage('Error sending selection.'); }; + config.failure = function(error) { + let msg = 'Error setting selection'; + if (error && error.exception) msg += ': ' + error.exception; + me.addMessage(msg, 'selection'); + }; } if (config.selectionKey) { @@ -3037,6 +3062,7 @@ if (!LABKEY.DataRegions) { // On success, update the current selectedCount on this DataRegion and fire the 'selectchange' event config.success = function(data) { + region.removeMessage('selection'); region.selectionModified = true; region.selectedCount = data.count; _onSelectionChange(region); @@ -3147,20 +3173,26 @@ if (!LABKEY.DataRegions) { return exists; }; + var _getDomIdSelector = function(region, suffix) { + let selector = '#' + region.domId; + if (suffix) selector += suffix; + return $(selector); + }; + var _getAllRowSelectors = function(region) { return _getFormSelector(region).find('.labkey-selectors input[type="checkbox"][name=".toggle"]'); }; var _getBarSelector = function(region) { - return $('#' + region.domId + '-headerbar'); + return _getDomIdSelector(region, '-headerbar'); }; var _getContextBarSelector = function(region) { - return $('#' + region.domId + '-ctxbar'); + return _getDomIdSelector(region, '-ctxbar'); }; var _getDrawerSelector = function(region) { - return $('#' + region.domId + '-drawer'); + return _getDomIdSelector(region, '-drawer'); }; var _getFormSelector = function(region) { @@ -3168,14 +3200,14 @@ if (!LABKEY.DataRegions) { // derived DataRegion's may not include the form id if (form.length === 0) { - form = $('#' + region.domId).closest('form'); + form = _getDomIdSelector(region).closest('form'); } return form; }; var _getHeaderSelector = function(region) { - return $('#' + region.domId + '-header'); + return _getDomIdSelector(region, '-header'); }; var _getRowSelectors = function(region) { @@ -3183,7 +3215,7 @@ if (!LABKEY.DataRegions) { }; var _getSectionSelector = function(region, dir) { - return $('#' + region.domId + '-section-' + dir); + return _getDomIdSelector(region, '-section-' + dir); }; var _getShowFirstSelector = function(region) { @@ -3198,6 +3230,10 @@ if (!LABKEY.DataRegions) { return $('#' + region.showAllID); }; + var _getNavTreeSelectAllSelector = function(region) { + return _getDomIdSelector(region, '-navtree-select-all'); + } + // Formerly, LABKEY.DataRegion.getParamValPairsFromString / LABKEY.DataRegion.getParamValPairs var _getParameters = function(region, skipPrefixSet /* optional */) { @@ -3321,7 +3357,7 @@ if (!LABKEY.DataRegions) { }; var _getViewBarSelector = function(region) { - return $('#' + region.domId + '-viewbar'); + return _getDomIdSelector(region, '-viewbar'); }; var _buttonSelectionBind = function(region, cls, fn) { @@ -3542,10 +3578,22 @@ if (!LABKEY.DataRegions) { _setParameter(region, SHOW_ROWS_PREFIX, showRowsEnum, [OFFSET_PREFIX, MAX_ROWS_PREFIX, SHOW_ROWS_PREFIX]); }; + var _getSelectAllText = function(region) { + let text = 'Select All Rows'; + if (region.totalRows > MAX_SELECTION_SIZE) { + text = `Select First ${MAX_SELECTION_SIZE.toLocaleString()} Rows`; + } + return text; + }; + + var _isShowSelectAll = function(region) { + return region.totalRows && region.totalRows !== region.selectedCount && region.selectedCount < MAX_SELECTION_SIZE; + }; + var _showSelectMessage = function(region, msg) { if (region.showRecordSelectors) { - if (region.totalRows && region.totalRows != region.selectedCount) { - msg += " Select All Rows"; + if (_isShowSelectAll(region)) { + msg += " " + _getSelectAllText(region) + ""; } msg += " " + "Select None"; @@ -3573,10 +3621,27 @@ if (!LABKEY.DataRegions) { } }); - _getAllRowSelectors(region).each(function() { this.checked = checked === true; }); + _getAllRowSelectors(region).each(function() { + this.checked = checked === true; + }); return ids; }; + var _checkRows = function(region, numRows) { + const rowSelectors = _getRowSelectors(region); + + for (let i = 0; i < rowSelectors.length; i++) { + const el = rowSelectors[i]; + if (!el.disabled) { + el.checked = i < numRows; + } + } + + _getAllRowSelectors(region).each(function() { + this.checked = true; + }); + } + /** * Asynchronous loader for a DataRegion * @param region {DataRegion} @@ -4038,8 +4103,8 @@ if (!LABKEY.DataRegions) { // If not all rows are visible and some rows are selected, show selection message if (region.totalRows && 0 !== region.selectedCount && !region.complete) { var msg = (region.selectedCount === region.totalRows) ? - 'All ' + region.totalRows + ' rows selected.' : - 'Selected ' + region.selectedCount + ' of ' + region.totalRows + ' rows.'; + 'All ' + region.totalRows.toLocaleString() + ' rows selected.' : + 'Selected ' + region.selectedCount.toLocaleString() + ' of ' + region.totalRows.toLocaleString() + ' rows.'; _showSelectMessage(region, msg); } diff --git a/query/src/org/labkey/query/QueryModule.java b/query/src/org/labkey/query/QueryModule.java index 16f6c82957c..7485cfe98ca 100644 --- a/query/src/org/labkey/query/QueryModule.java +++ b/query/src/org/labkey/query/QueryModule.java @@ -25,6 +25,7 @@ import org.labkey.api.data.Aggregate; import org.labkey.api.data.Container; import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DataRegionSelection; import org.labkey.api.data.JdbcType; import org.labkey.api.data.views.DataViewService; import org.labkey.api.exp.property.PropertyService; @@ -416,6 +417,7 @@ public JSONObject getPageContextJson(ContainerUser context) json.put(QueryService.PRODUCT_FOLDERS_EXIST, isProductFoldersEnabled && container.hasProductFolders()); json.put(QueryService.EXPERIMENTAL_PRODUCT_ALL_FOLDER_LOOKUPS, QueryService.get().isProductFoldersAllFolderScopeEnabled()); json.put(QueryService.EXPERIMENTAL_PRODUCT_PROJECT_DATA_LISTING_SCOPED, QueryService.get().isProductFoldersDataListingScopedToProject()); + json.put(QueryService.MAX_QUERY_SELECTION, DataRegionSelection.MAX_QUERY_SELECTION_SIZE); return json; } } diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index 258eb3d0b91..4b37da7c7dd 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -328,6 +328,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -6510,14 +6511,9 @@ public ApiResponse execute(final SelectForm form, BindException errors) throws E DataRegionSelection.clearAll(getViewContext(), form.getKey()); return new DataRegionSelection.SelectionResponse(0); } - else - { - // Note: DataRegionSelection.setSelectionForAll(form, false) will query for all rows in the region to remove those in context. - // Instead, get the selected values in context and then "uncheck" them directly - List currentCtxSelection = DataRegionSelection.getSelected(form, false); - int count = DataRegionSelection.setSelected(getViewContext(), form.getQuerySettings().getSelectionKey(), currentCtxSelection, false); - return new DataRegionSelection.SelectionResponse(count); - } + + int count = DataRegionSelection.setSelectedFromForm(form); + return new DataRegionSelection.SelectionResponse(count); } } @@ -6587,16 +6583,14 @@ public void validateForm(SelectForm form, Errors errors) public ApiResponse execute(final SelectForm form, BindException errors) throws Exception { getViewContext().getResponse().setHeader("Content-Type", CONTENT_TYPE_JSON); + Set selected; + if (form.getQueryName() == null) - { - Set selected = DataRegionSelection.getSelected(getViewContext(), form.getKey(), form.isClearSelected()); - return new ApiSimpleResponse("selected", selected); - } + selected = DataRegionSelection.getSelected(getViewContext(), form.getKey(), form.isClearSelected()); else - { - List selected = DataRegionSelection.getSelected(form, form.isClearSelected()); - return new ApiSimpleResponse("selected", selected); - } + selected = DataRegionSelection.getSelected(form, form.isClearSelected()); + + return new ApiSimpleResponse("selected", selected); } } @@ -6608,7 +6602,7 @@ public static class SetCheckAction extends MutatingApiAction public ApiResponse execute(final SetCheckForm form, BindException errors) throws Exception { String[] ids = form.getId(getViewContext().getRequest()); - List selection = new ArrayList<>(); + Set selection = new LinkedHashSet<>(); if (ids != null) { for (String id : ids)