diff --git a/src/org/labkey/remoteapi/query/SaveQueryViewsCommand.java b/src/org/labkey/remoteapi/query/SaveQueryViewsCommand.java new file mode 100644 index 0000000000..4155b0c0f3 --- /dev/null +++ b/src/org/labkey/remoteapi/query/SaveQueryViewsCommand.java @@ -0,0 +1,75 @@ +/* + * 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.remoteapi.query; + +import org.json.JSONObject; +import org.labkey.remoteapi.CommandResponse; +import org.labkey.remoteapi.PostCommand; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Saves custom grid views via query-saveQueryViews.api. + */ +public class SaveQueryViewsCommand extends PostCommand +{ + private final String _schemaName; + private final String _queryName; + private final List> _views = new ArrayList<>(); + + public SaveQueryViewsCommand(String schemaName, String queryName) + { + super("query", "saveQueryViews"); + _schemaName = schemaName; + _queryName = queryName; + } + + /** + * @param viewName the view name; empty for the default view + * @param columns field keys to show, in order; at least one is required + * @param shared make the view available to all users + * @param inherit make the view available in child folders + */ + public SaveQueryViewsCommand addView(String viewName, List columns, boolean shared, boolean inherit) + { + List> columnList = columns.stream() + .map(fieldKey -> Map.of("fieldKey", fieldKey)) + .toList(); + + Map view = new HashMap<>(); + view.put("name", viewName); + view.put("columns", columnList); + view.put("shared", shared); + view.put("inherit", inherit); + view.put("replace", true); + _views.add(view); + + return this; + } + + @Override + public JSONObject getJsonObject() + { + JSONObject json = new JSONObject(); + json.put("schemaName", _schemaName); + json.put("queryName", _queryName); + json.put("views", _views); + return json; + } +} diff --git a/src/org/labkey/test/tests/CustomizeViewTest.java b/src/org/labkey/test/tests/CustomizeViewTest.java index 8220a8289c..e2090d5655 100644 --- a/src/org/labkey/test/tests/CustomizeViewTest.java +++ b/src/org/labkey/test/tests/CustomizeViewTest.java @@ -20,9 +20,11 @@ import org.junit.BeforeClass; import org.junit.Test; import org.junit.experimental.categories.Category; +import org.labkey.remoteapi.query.SaveQueryViewsCommand; import org.labkey.test.BaseWebDriverTest; import org.labkey.test.Locator; import org.labkey.test.SortDirection; +import org.labkey.test.WebTestHelper; import org.labkey.test.categories.Daily; import org.labkey.test.pages.list.GridPage; import org.labkey.test.params.FieldDefinition; @@ -82,6 +84,12 @@ public class CustomizeViewTest extends BaseWebDriverTest private final static int WIDE_LIST_COLUMN_COUNT = 160; private final static String WIDE_STAT_COLUMN = "Field001"; + // GitHub Issue 899: view used for subfolder testing + private final static String SUBFOLDER_NAME = "InheritViewSubfolder"; + private final static String INHERIT_LIST_NAME = "InheritViewList"; + private final static String INHERITED_VIEW_NAME = "ProjectWideView"; + private final static List INHERITED_VIEW_COLUMNS = List.of(LIST_KEY_COLUMN, FIRST_NAME_COLUMN, AGE_COLUMN); + private SummaryStatisticsHelper _summaryStatisticsHelper; @Override @@ -102,6 +110,9 @@ private void doSetup() throws Exception _containerHelper.createProject(PROJECT_NAME, null); createList(); createWideList(); + + _containerHelper.createSubfolder(PROJECT_NAME, SUBFOLDER_NAME); + createInheritViewList(); } @Before @@ -406,6 +417,63 @@ public void testCustomViewsQueryTable() assertEquals("Deleted view should no longer appear in the query.CustomViews table", -1, drt.getRowIndex("Name", viewName)); } + // GitHub Issue 899 + @Test + public void testSaveInheritedViewFromSubfolder() throws Exception + { + final String subfolderPath = PROJECT_NAME + "/" + SUBFOLDER_NAME; + + new SaveQueryViewsCommand("lists", INHERIT_LIST_NAME) + .addView(INHERITED_VIEW_NAME, INHERITED_VIEW_COLUMNS, true, true) + .execute(createDefaultConnection(), PROJECT_NAME); + + GridPage.beginAt(this, subfolderPath, INHERIT_LIST_NAME); + DataRegionTable grid = new DataRegionTable("query", getDriver()); + grid.goToView(INHERITED_VIEW_NAME); + assertEquals("Subfolder should start out showing the view inherited from the project", + INHERITED_VIEW_COLUMNS, grid.getColumnNames()); + + // Leaving "available in child folders" unchecked is what makes the save target folder implicit + _customizeViewsHelper.openCustomizeViewPanel(); + _customizeViewsHelper.removeColumn(FieldKey.fromParts(AGE_COLUMN).toString()); + _customizeViewsHelper.saveCustomView(INHERITED_VIEW_NAME, true, false); + + grid = new DataRegionTable("query", getDriver()); + assertEquals("Subfolder save should shadow the inherited view with a local copy", + List.of(LIST_KEY_COLUMN, FIRST_NAME_COLUMN), grid.getColumnNames()); + + // The subfolder save used to relocate the project's view rather than shadow it, so the project lost the view entirely + assertEquals("Project's view should still be inheritable", "true", + getCustomViewValue(PROJECT_NAME, INHERITED_VIEW_NAME, "Inheritable")); + assertEquals("Shadowing view should be local to the subfolder", "false", + getCustomViewValue(subfolderPath, INHERITED_VIEW_NAME, "Inheritable")); + + GridPage.beginAt(this, PROJECT_NAME, INHERIT_LIST_NAME); + grid = new DataRegionTable("query", getDriver()); + grid.goToView(INHERITED_VIEW_NAME); + assertEquals("Project's view should be untouched by the subfolder save", + INHERITED_VIEW_COLUMNS, grid.getColumnNames()); + } + + // query.CustomViews only shows views owned by the container, so a row here proves where the view lives + private String getCustomViewValue(String containerPath, String viewName, String column) + { + beginAt(WebTestHelper.buildURL("query", containerPath, "executeQuery", Map.of("schemaName", "query", "queryName", "CustomViews"))); + DataRegionTable customViews = new DataRegionTable("query", getDriver()); + int rowIndex = customViews.getRowIndex("Name", viewName); + assertNotEquals(String.format("View '%s' should be owned by folder '%s'", viewName, containerPath), -1, rowIndex); + return customViews.getDataAsText(rowIndex, column); + } + + private void createInheritViewList() throws Exception + { + new IntListDefinition(INHERIT_LIST_NAME, LIST_KEY_COLUMN) + .setFields(List.of( + new FieldDefinition(FIRST_NAME_COLUMN, ColumnType.String), + new FieldDefinition(AGE_COLUMN, ColumnType.Integer))) + .create(createDefaultConnection(), PROJECT_NAME); + } + private void createList() throws Exception { ListDefinition listDefinition = new IntListDefinition(LIST_NAME, LIST_KEY_COLUMN).setFields(LIST_COLUMNS); diff --git a/src/org/labkey/test/tests/component/GridPanelViewTest.java b/src/org/labkey/test/tests/component/GridPanelViewTest.java index 268a29c686..da44533a33 100644 --- a/src/org/labkey/test/tests/component/GridPanelViewTest.java +++ b/src/org/labkey/test/tests/component/GridPanelViewTest.java @@ -979,6 +979,7 @@ public void testSaveViewTrickyName() throws Exception log(String.format("Save the view name as '%s'.", trickyViewName)); saveViewDialog.setViewName(trickyViewName) + .setMakeShared(false) .saveView(); // Add this view name to the list of views. @@ -1234,7 +1235,12 @@ public void testManageViews() throws Exception String viewName1 = String.format("No %s", fieldRemoved1); log(String.format("Use the 'Save' button on the grid to save the view as '%s'.", viewName1)); SaveViewDialog saveViewDialog = grid.clickSaveButton(true); + + log("GitHub Issue #899: Expect shared checkbox for session view to match original / shadowed view"); + checker().verifyTrue("View should default to shared", saveViewDialog.isMakeSharedChecked()); + saveViewDialog.setViewName(viewName1) + .setMakeShared(false) .saveView(); log(String.format("Go back to '%s' and create a new view.", VIEW_DEFAULT)); @@ -1252,6 +1258,7 @@ public void testManageViews() throws Exception log(String.format("Use the menu to save the view as '%s'. This is now the current view.", viewName2)); saveViewDialog = grid.saveView(); saveViewDialog.setViewName(viewName2) + .setMakeShared(false) .saveView(); ManageViewsDialog manageViewsDialog = grid.manageViews(); @@ -1347,6 +1354,7 @@ public void testManageViews() throws Exception String viewName3 = "No Bool"; log(String.format("Change view name to something new, '%s' and validate save works as expected.", viewName3)); saveViewDialog.setViewName(viewName3); + saveViewDialog.setMakeShared(false); saveViewDialog.saveView(); checker().withScreenshot("New_View_Name_Error") @@ -1373,6 +1381,7 @@ public void testManageViews() throws Exception grid.hideColumn(fieldRemoved1); saveViewDialog = grid.saveView(); saveViewDialog.setViewName(viewName1); + saveViewDialog.setMakeShared(false); saveViewDialog.saveView(); manageViewsDialog = grid.manageViews();