diff --git a/src/org/labkey/test/components/domain/DomainFieldRow.java b/src/org/labkey/test/components/domain/DomainFieldRow.java index d78be327e5..bfc86b2cb0 100644 --- a/src/org/labkey/test/components/domain/DomainFieldRow.java +++ b/src/org/labkey/test/components/domain/DomainFieldRow.java @@ -767,11 +767,19 @@ public DomainFieldRow clickRemoveOntologyConcept() // behind the scenes. Because of that the validator aspect of the TextChoice field is hidden from the user (just like // it is in the product). - public void setAllowMultipleSelections(Boolean allowMultipleSelections) + public DomainFieldRow setAllowMultipleSelections(Boolean allowMultipleSelections) { WebDriverWrapper.waitFor(() -> elementCache().allowMultipleSelectionsCheckbox.isDisplayed(), "Allow Multiple Selections checkbox did not become visible", 1000); + WebDriverWrapper.waitFor(() -> elementCache().allowMultipleSelectionsCheckbox.isEnabled(), + "Allow Multiple Selections checkbox isn't enabled", 1000); elementCache().allowMultipleSelectionsCheckbox.set(allowMultipleSelections); + // A confirmation dialog may appear when re-enabling multiple selections; dismiss it if present + ModalDialog modal = new ModalDialog.ModalDialogFinder(getDriver()) + .withTitle("Confirm Data Type Change").findOrNull(getDriver()); + if (modal != null) + modal.dismiss("Yes, Change Data Type"); + return this; } /** diff --git a/src/org/labkey/test/components/ui/search/FilterExpressionPanel.java b/src/org/labkey/test/components/ui/search/FilterExpressionPanel.java index 6020bae857..b1cd326742 100644 --- a/src/org/labkey/test/components/ui/search/FilterExpressionPanel.java +++ b/src/org/labkey/test/components/ui/search/FilterExpressionPanel.java @@ -27,7 +27,8 @@ public class FilterExpressionPanel extends WebDriverComponent getAssociatedModules() + { + return Arrays.asList("experiment"); + } + + @Override + protected String getProjectName() + { + return "MultiValueTextChoice_SampleType_Test"; + } + + @BeforeClass + public static void setupProject() + { + MultiValueTextChoiceSampleTypeTest init = getCurrentTest(); + init.doSetup(); + } + + private void doSetup() + { + PortalHelper portalHelper = new PortalHelper(this); + _containerHelper.createProject(getProjectName(), null); + portalHelper.enterAdminMode(); + portalHelper.addWebPart("Sample Types"); + _containerHelper.createSubfolder(getProjectName(), SUB_FOLDER); + portalHelper.addWebPart("Sample Types"); + portalHelper.exitAdminMode(); + } + + @Before + public void beforeTest() + { + goToProjectHome(); + } + + private TestDataGenerator createSampleType(String sampleTypeName, String sampleNamePrefix, String multiValueTextChoiceFieldName, List multiValueTextChoiceValues) + { + log(String.format("Create a new sample type named '%s'.", sampleTypeName)); + SampleTypeDefinition sampleTypeDefinition = new SampleTypeDefinition(sampleTypeName); + sampleTypeDefinition.setNameExpression(String.format("%s${genId}", sampleNamePrefix)); + + log(String.format("Add a MultiValueTextChoice field named '%s'.", multiValueTextChoiceFieldName)); + FieldDefinition textChoiceField = new FieldDefinition(multiValueTextChoiceFieldName, ColumnType.MultiValueTextChoice); + textChoiceField.setMultiChoiceValues(multiValueTextChoiceValues); + + sampleTypeDefinition.addField(textChoiceField); + + return SampleTypeAPIHelper.createEmptySampleType(getCurrentContainerPath(), sampleTypeDefinition); + } + + /** + * Validate cross folder MVTC to TC conversion. + */ + @Test + public void testCrossFolderMVTCtoTCConversion() throws IOException, CommandException + { + final String sampleTypeName = randomDomainName("MVTC_Sample_Edit", DomainUtils.DomainKind.SampleSet); + final String multiValueTextChoiceFieldName = randomFieldName("MultiValueTextChoice_Field"); + final String namePrefix = "MVTC_"; + int samplesCount = 3; + List mvtcValues = randomTextChoice(10); + + // Create Sample type in main folder. + TestDataGenerator dataGenerator = createSampleType(sampleTypeName, namePrefix, multiValueTextChoiceFieldName, mvtcValues); + + log("Create some samples in child folder. They have MultiValueTextChoice filed filled with random multiple values."); + + for (int i = 1; i <= samplesCount; i++) + { + Map sample = new HashMap<>(); + String sampleName = String.format("%s%d", namePrefix, i); + sample.put("Name", sampleName); + sample.put(multiValueTextChoiceFieldName, shuffleSelect(mvtcValues, 2)); + dataGenerator.addCustomRow(sample); + } + + dataGenerator.insertRows(WebTestHelper.getRemoteApiConnection(), SUB_FOLDER_PATH); + + // Check that impossible to convert MVTC to TC. + DomainFieldRow fieldRow = beginAtSampleTypesList(this, getProjectName()) + .goToEditSampleType(sampleTypeName) + .getFieldsPanel() + .getField(multiValueTextChoiceFieldName) + .expand(); + checker().wrapAssertion(() -> + assertThatThrownBy(() -> fieldRow.setAllowMultipleSelections(false)) + .as("'Allow Multiple Selections' checkbox should not be available") + .hasMessageContaining("Allow Multiple Selections checkbox isn't enabled") + ); + + // Edit all MVTC fields to have 1 chosen value. + DataRegionTable samplesTable = beginAtSampleTypesList(this, SUB_FOLDER_PATH) + .goToSampleType(sampleTypeName) + .getSamplesDataRegionTable(); + + for (int i = 0; i < samplesCount; i++) + { + UpdateQueryRowPage updateQueryRowPage = samplesTable.clickEditRow(i); + updateQueryRowPage.setField(multiValueTextChoiceFieldName, shuffleSelect(mvtcValues, 1)); + updateQueryRowPage.submit(); + } + + // Convert MVTC to TC. + UpdateSampleTypePage updateSampleTypePage = beginAtSampleTypesList(this, getProjectName()) + .goToEditSampleType(sampleTypeName); + updateSampleTypePage.getFieldsPanel() + .getField(multiValueTextChoiceFieldName) + .expand() + .setAllowMultipleSelections(false); + updateSampleTypePage.clickSave(); + + // Check that impossible to choose multiple values. + samplesTable = beginAtSampleTypesList(this, SUB_FOLDER_PATH) + .goToSampleType(sampleTypeName) + .getSamplesDataRegionTable(); + UpdateQueryRowPage updateQueryRowPage = samplesTable.clickEditRow(0); + checker().wrapAssertion(() -> + assertThatThrownBy(() -> updateQueryRowPage.setField(multiValueTextChoiceFieldName, shuffleSelect(mvtcValues, 2))) + .as("MVTC element isn't found on the page.") + .hasMessageContaining("Unable to find element") + ); + } + + @Override + protected void doCleanup(boolean afterTest) + { + _containerHelper.deleteProject(getProjectName(), false); + } +} diff --git a/src/org/labkey/test/tests/component/GridPanelViewTest.java b/src/org/labkey/test/tests/component/GridPanelViewTest.java index bc778ae7b4..bf116ffaf5 100644 --- a/src/org/labkey/test/tests/component/GridPanelViewTest.java +++ b/src/org/labkey/test/tests/component/GridPanelViewTest.java @@ -1,6 +1,8 @@ package org.labkey.test.tests.component; import org.assertj.core.api.Assertions; +import org.jetbrains.annotations.Nullable; +import org.junit.Assume; import org.junit.BeforeClass; import org.junit.Test; import org.junit.experimental.categories.Category; @@ -13,6 +15,8 @@ import org.labkey.remoteapi.query.Sort; import org.labkey.test.Locator; import org.labkey.test.SortDirection; +import org.labkey.test.WebTestHelper; +import org.labkey.test.WebTestHelper.DatabaseType; import org.labkey.test.categories.Daily; import org.labkey.test.components.CustomizeView; import org.labkey.test.components.bootstrap.ModalDialog; @@ -24,10 +28,10 @@ import org.labkey.test.components.ui.grids.SaveViewDialog; import org.labkey.test.components.ui.search.FilterExpressionPanel; import org.labkey.test.components.ui.search.FilterFacetedPanel; +import org.labkey.test.pages.experiment.UpdateSampleTypePage; import org.labkey.test.params.FieldDefinition; import org.labkey.test.params.FieldKey; import org.labkey.test.params.experiment.SampleTypeDefinition; -import org.labkey.test.WebTestHelper; import org.labkey.test.util.APIUserHelper; import org.labkey.test.util.ApiPermissionsHelper; import org.labkey.test.util.AuditLogHelper; @@ -35,6 +39,7 @@ import org.labkey.test.util.PermissionsHelper; import org.labkey.test.util.SampleTypeHelper; import org.labkey.test.util.TestDataGenerator; +import org.labkey.test.util.data.TestArrayDataUtils; import org.labkey.test.util.exp.SampleTypeAPIHelper; import org.openqa.selenium.WebDriverException; @@ -44,10 +49,15 @@ import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Predicate; import static org.labkey.test.util.PermissionsHelper.FOLDER_ADMIN_ROLE; +import static org.labkey.test.util.SampleTypeHelper.beginAtSampleTypesList; +import static org.labkey.test.util.TestDataGenerator.randomFieldName; +import static org.labkey.test.util.TestDataGenerator.randomTextChoice; @Category({Daily.class}) public class GridPanelViewTest extends GridPanelBaseTest @@ -69,8 +79,13 @@ public class GridPanelViewTest extends GridPanelBaseTest private static final String COL_STRING2 = "Str2"; private static final String COL_INT = "Int"; private static final String COL_BOOL = "Bool"; + public static final List TEXT_MULTI_CHOICE_LIST = randomTextChoice(10); + public static final String COL_MULTITEXTCHOICE = randomFieldName("Multi Choice", 20); - private static final List DEFAULT_COLUMNS = Arrays.asList(COL_NAME, COL_INT, COL_STRING1, COL_STRING2, COL_BOOL); + private static final boolean MULTI_CHOICE_ENABLED = WebTestHelper.getDatabaseType() == DatabaseType.PostgreSQL; + private static final List DEFAULT_COLUMNS = MULTI_CHOICE_ENABLED + ? Arrays.asList(COL_NAME, COL_INT, COL_STRING1, COL_STRING2, COL_BOOL, COL_MULTITEXTCHOICE) + : Arrays.asList(COL_NAME, COL_INT, COL_STRING1, COL_STRING2, COL_BOOL); // Will keep track of state of the columns, that is are they filtered, sorted, or have no modifiers. private static Map defaultColumnState = new HashMap<>(); @@ -96,6 +111,20 @@ public class GridPanelViewTest extends GridPanelBaseTest private final AuditLogHelper _auditLogHelper = new AuditLogHelper(this); + private record GridConversionResult(boolean isDropped, @Nullable String expectedFilterText) + { + static GridConversionResult kept(String filterText) { return new GridConversionResult(false, filterText); } + static GridConversionResult dropped() { return new GridConversionResult(true, null); } + } + + private record GridMVTCCase(String viewName, Filter.Operator op, String[] filterVals, + GridConversionResult conversionResult, + Map expectedSampleMap) {} + + private record GridTCCase(String viewName, Filter.Operator op, String[] filterVals, + String expectedAfterConversionText, + Map expectedSampleMap) {} + // Tests that need to be written: // Validate "Save As..." from the grid save button. // Validate views that are locked, or in some other way, cannot be updates in the manage views dialog. @@ -133,10 +162,14 @@ private void doSetup() throws IOException, CommandException // Create a sample type that will validate views can be saved and shared. Primarily interested in the views not // with complex filtering scenarios. - List fields = Arrays.asList(new FieldDefinition(COL_INT, FieldDefinition.ColumnType.Integer), + List fields = new ArrayList<>(Arrays.asList( + new FieldDefinition(COL_INT, FieldDefinition.ColumnType.Integer), new FieldDefinition(COL_STRING1, FieldDefinition.ColumnType.String), new FieldDefinition(COL_STRING2, FieldDefinition.ColumnType.String), - new FieldDefinition(COL_BOOL, FieldDefinition.ColumnType.Boolean)); + new FieldDefinition(COL_BOOL, FieldDefinition.ColumnType.Boolean))); + if (MULTI_CHOICE_ENABLED) + fields.add(new FieldDefinition(COL_MULTITEXTCHOICE, FieldDefinition.ColumnType.MultiValueTextChoice) + .setMultiChoiceValues(TEXT_MULTI_CHOICE_LIST)); createSampleType(VIEW_DIALOG_ST, VIEW_DIALOG_ST_PREFIX, VIEW_DIALOG_ST_SIZE, fields); @@ -145,16 +178,25 @@ private void doSetup() throws IOException, CommandException _userHelper.createUser(OTHER_USER, true,false); new ApiPermissionsHelper(this).addMemberToRole(OTHER_USER, FOLDER_ADMIN_ROLE, PermissionsHelper.MemberType.user, getProjectName()); + removeFlagColumnFromDefaultView(DEFAULT_VIEW_SAMPLE_TYPE); } private void createSampleType(String sampleTypeName, String samplePrefix, int numOfSamples, List fields) throws IOException, CommandException { - SampleTypeDefinition props = new SampleTypeDefinition(sampleTypeName) .setFields(fields); TestDataGenerator sampleSetDataGenerator = SampleTypeAPIHelper.createEmptySampleType(getProjectName(), props); + generateSamples(sampleSetDataGenerator, samplePrefix, numOfSamples); + + sampleSetDataGenerator.insertRows(); + + removeFlagColumnFromDefaultView(sampleTypeName); + } + + private void generateSamples(TestDataGenerator sampleSetDataGenerator, String samplePrefix, int numOfSamples) throws IOException, CommandException + { int sampleId = 1; int allPossibleIndex = 0; int memIndex = 0; @@ -167,21 +209,25 @@ private void createSampleType(String sampleTypeName, String samplePrefix, int nu if(memIndex == stringSetMembers.size()) memIndex = 0; - sampleSetDataGenerator.addCustomRow( - Map.of(COL_NAME, String.format("%s%d", samplePrefix, sampleId), - COL_INT, sampleId, - COL_STRING1, stringSets.get(allPossibleIndex), - COL_STRING2, stringSetMembers.get(memIndex), - COL_BOOL, sampleId % 2 == 0)); + String name = String.format("%s%d", samplePrefix, sampleId); + Map rowData = new HashMap<>(); + rowData.put(COL_NAME, name); + rowData.put(COL_INT, sampleId); + rowData.put(COL_STRING1, stringSets.get(allPossibleIndex)); + rowData.put(COL_STRING2, stringSetMembers.get(memIndex)); + rowData.put(COL_BOOL, sampleId % 2 == 0); + if (MULTI_CHOICE_ENABLED) + { + rowData.put(COL_MULTITEXTCHOICE, sampleId % 5 == 0 + ? List.of() + : List.of(TEXT_MULTI_CHOICE_LIST.get(Math.abs(name.hashCode()) % TEXT_MULTI_CHOICE_LIST.size()))); + } + sampleSetDataGenerator.addCustomRow(rowData); allPossibleIndex++; memIndex++; sampleId++; } - - sampleSetDataGenerator.insertRows(); - - removeFlagColumnFromDefaultView(sampleTypeName); } /** @@ -190,11 +236,11 @@ private void createSampleType(String sampleTypeName, String samplePrefix, int nu * @param sampleTypeName Name of the sample type with the default view to change. * @param columns The columns to show in the default view. Will be added in the order of the list. */ - private void resetDefaultView(String sampleTypeName, List columns) throws Exception + private void resetDefaultView(String projectPath, String sampleTypeName, List columns) throws Exception { log(String.format("Set the default view for '%s' to have these columns: '%s'", sampleTypeName, columns)); - goToProjectHome(); + goToProjectHome(projectPath); waitAndClickAndWait(Locator.linkWithText(sampleTypeName)); SampleTypeHelper sampleHelper = new SampleTypeHelper(this); DataRegionTable drtSamples = sampleHelper.getSamplesDataRegionTable(); @@ -290,7 +336,7 @@ public void testMyDefaultView() throws Exception { String screenShotID = "testMyDefaultView"; - resetDefaultView(DEFAULT_VIEW_SAMPLE_TYPE, DEFAULT_COLUMNS); + resetDefaultView(getProjectName(), DEFAULT_VIEW_SAMPLE_TYPE, DEFAULT_COLUMNS); goToProjectHome(); @@ -388,7 +434,7 @@ public void testRemoveColumnForView() throws Exception final String screenShotPrefix = "testRemoveColumnForView"; - resetDefaultView(DEFAULT_VIEW_SAMPLE_TYPE, DEFAULT_COLUMNS); + resetDefaultView(getProjectName(), DEFAULT_VIEW_SAMPLE_TYPE, DEFAULT_COLUMNS); String columnToRemove = COL_BOOL; log(String.format("For sample type '%s' remove the '%s' column using the column header menu.", DEFAULT_VIEW_SAMPLE_TYPE, columnToRemove)); @@ -502,7 +548,7 @@ public void testColumnHeaderAndFilterPillCustomView() throws Exception public void testColumnHeaderAndFilterPill(String testName, String viewName) throws Exception { - resetDefaultView(DEFAULT_VIEW_SAMPLE_TYPE, DEFAULT_COLUMNS); + resetDefaultView(getProjectName(), DEFAULT_VIEW_SAMPLE_TYPE, DEFAULT_COLUMNS); QueryGrid grid = beginAtQueryGrid(DEFAULT_VIEW_SAMPLE_TYPE); @@ -662,7 +708,7 @@ public void testEditDefaultView() throws Exception private void testEditView(String testName, String viewName) throws Exception { - resetDefaultView(DEFAULT_VIEW_SAMPLE_TYPE, DEFAULT_COLUMNS); + resetDefaultView(getProjectName(), DEFAULT_VIEW_SAMPLE_TYPE, DEFAULT_COLUMNS); String filterCol1 = COL_STRING1; String filterValue1 = stringSetMembers.get(2); @@ -886,7 +932,7 @@ private void testEditView(String testName, String viewName) throws Exception public void testSaveViewTrickyName() throws Exception { - resetDefaultView(DEFAULT_VIEW_SAMPLE_TYPE, DEFAULT_COLUMNS); + resetDefaultView(getProjectName(), DEFAULT_VIEW_SAMPLE_TYPE, DEFAULT_COLUMNS); String hideCol = COL_INT; @@ -941,7 +987,7 @@ public void testFieldInsertionOrder() throws Exception { goToProjectHome(); - resetDefaultView(VIEW_DIALOG_ST, DEFAULT_COLUMNS); + resetDefaultView(getProjectName(), VIEW_DIALOG_ST, DEFAULT_COLUMNS); QueryGrid grid = beginAtQueryGrid(VIEW_DIALOG_ST); @@ -992,7 +1038,7 @@ public void testFieldInsertionOrder() throws Exception customizeModal.isAvailableFieldSelected(columnToAdd)); log("Validate that the order of the fields in the 'Shown in Grid' column are as expected."); - expectedFields = List.of(COL_NAME, COL_STRING1, COL_STRING2, COL_INT, COL_BOOL); + expectedFields = List.of(COL_NAME, COL_STRING1, COL_STRING2, COL_INT, COL_BOOL, COL_MULTITEXTCHOICE); checker().verifyEquals(String.format("After adding '%s' fields displayed in 'Show in Grid' panel not as expected.", columnToAdd), expectedFields, customizeModal.getSelectedFieldLabels()); @@ -1026,7 +1072,7 @@ public void testShowAllLabelEditAndUndo() throws Exception { goToProjectHome(); - resetDefaultView(VIEW_DIALOG_ST, DEFAULT_COLUMNS); + resetDefaultView(getProjectName(), VIEW_DIALOG_ST, DEFAULT_COLUMNS); QueryGrid grid = beginAtQueryGrid(VIEW_DIALOG_ST); @@ -1139,7 +1185,7 @@ public void testManageViews() throws Exception { goToProjectHome(); - resetDefaultView(VIEW_DIALOG_ST, DEFAULT_COLUMNS); + resetDefaultView(getProjectName(), VIEW_DIALOG_ST, DEFAULT_COLUMNS); QueryGrid grid = beginAtQueryGrid(VIEW_DIALOG_ST); @@ -1314,7 +1360,7 @@ public void testRemoveAllFields() throws Exception goToProjectHome(); - resetDefaultView(VIEW_DIALOG_ST, DEFAULT_COLUMNS); + resetDefaultView(getProjectName(), VIEW_DIALOG_ST, DEFAULT_COLUMNS); QueryGrid grid = beginAtQueryGrid(VIEW_DIALOG_ST); @@ -1348,7 +1394,7 @@ public void testWarningOnInvalidDateFilter() throws Exception String viewName = "broken view"; goToProjectHome(); - resetDefaultView(VIEW_DIALOG_ST, DEFAULT_COLUMNS); + resetDefaultView(getProjectName(), VIEW_DIALOG_ST, DEFAULT_COLUMNS); QueryGrid grid = beginAtQueryGrid(VIEW_DIALOG_ST); @@ -1393,6 +1439,373 @@ public void testWarningOnInvalidDateFilter() throws Exception .dismiss("Done"); } + @Test + public void testCustomGridViewsMVTCtoTC() throws Exception + { + Assume.assumeTrue("Multi-choice text fields are only supported on PostgreSQL", MULTI_CHOICE_ENABLED); + goToProjectHome(); + resetDefaultView(getProjectName(), DEFAULT_VIEW_SAMPLE_TYPE, DEFAULT_COLUMNS); + + log("Compute expected sample data for MVTC conversion test."); + Map allSamples = createSampleMVTCMap(); + String filterValue = TEXT_MULTI_CHOICE_LIST.get(0); + String filterValue2 = TEXT_MULTI_CHOICE_LIST.get(1); + + Map emptyMap = filterSampleMap(allSamples, v -> v == null); + Map nonemptyMap = filterSampleMap(allSamples, v -> v != null); + Map containsAnyMap = filterSampleMap(allSamples, v -> filterValue.equals(v) || filterValue2.equals(v)); + Map containsExactMap = filterSampleMap(allSamples, v -> filterValue.equals(v)); + Map containsNotEqMap = filterSampleMap(allSamples, v -> v == null || !filterValue.equals(v)); + Map containsNoneMap = filterSampleMap(allSamples, v -> v == null || (!filterValue.equals(v) && !filterValue2.equals(v))); + + List cases = List.of( + new GridMVTCCase("MVTC→TC Grid - Is Empty", Filter.Operator.ARRAY_ISEMPTY, new String[0], + GridConversionResult.kept(Filter.Operator.ISBLANK.getDisplayValue()), emptyMap), + new GridMVTCCase("MVTC→TC Grid - Contains Any", Filter.Operator.ARRAY_CONTAINS_ANY, new String[]{filterValue, filterValue2}, + GridConversionResult.kept(Filter.Operator.IN.getDisplayValue()), containsAnyMap), + new GridMVTCCase("MVTC→TC Grid - Contains All", Filter.Operator.ARRAY_CONTAINS_ALL, new String[]{filterValue, filterValue2}, + GridConversionResult.dropped(), containsAnyMap), + new GridMVTCCase("MVTC→TC Grid - Is Not Empty", Filter.Operator.ARRAY_ISNOTEMPTY, new String[0], + GridConversionResult.kept(Filter.Operator.NONBLANK.getDisplayValue()), nonemptyMap), + new GridMVTCCase("MVTC→TC Grid - Contains Not Exact", Filter.Operator.ARRAY_CONTAINS_NOT_EXACT, new String[]{filterValue}, + GridConversionResult.kept(filterValue), containsNotEqMap), + new GridMVTCCase("MVTC→TC Grid - Contains Exact", Filter.Operator.ARRAY_CONTAINS_EXACT, new String[]{filterValue}, + GridConversionResult.kept(filterValue), containsExactMap), + new GridMVTCCase("MVTC→TC Grid - Contains None", Filter.Operator.ARRAY_CONTAINS_NONE, new String[]{filterValue, filterValue2}, + GridConversionResult.kept(filterValue), containsNoneMap) + ); + + log("Creating saved grid views with all MVTC operators on column '" + COL_MULTITEXTCHOICE + "'."); + List viewNames = cases.stream().map(GridMVTCCase::viewName).toList(); + for (GridMVTCCase c : cases) + createMVTCGridView(c.viewName(), c.op(), c.filterVals()); + + log("Converting '" + COL_MULTITEXTCHOICE + "' field of " + DEFAULT_VIEW_SAMPLE_TYPE + " from MVTC to TextChoice."); + enableMVTCFieldMultiSelect(false); + + log("Verifying saved grid views after MVTC → TC conversion."); + verifyGridMVTCCases(cases); + checker().screenShotIfNewError("GridMVTCtoTC_Error"); + + log("Restoring field back to MultiValueTextChoice."); + enableMVTCFieldMultiSelect(true); + + log("Cleaning up MVTC→TC test views."); + cleanupGridViews(beginAtQueryGrid(DEFAULT_VIEW_SAMPLE_TYPE), viewNames); + } + + @Test + public void testCustomGridViewsTCtoMVTC() throws Exception + { + Assume.assumeTrue("Multi-choice text fields are only supported on PostgreSQL", MULTI_CHOICE_ENABLED); + goToProjectHome(); + resetDefaultView(getProjectName(), DEFAULT_VIEW_SAMPLE_TYPE, DEFAULT_COLUMNS); + + log("Converting '" + COL_MULTITEXTCHOICE + "' field of " + DEFAULT_VIEW_SAMPLE_TYPE + " from MVTC to TextChoice for TC→MVTC test setup."); + enableMVTCFieldMultiSelect(false); + + log("Compute expected sample data for TC conversion test."); + Map allSamples = createSampleMVTCMap(); + String filterValue = TEXT_MULTI_CHOICE_LIST.get(0); + String filterValue2 = TEXT_MULTI_CHOICE_LIST.get(1); + + Map emptyMap = filterSampleMap(allSamples, v -> v == null); + Map nonemptyMap = filterSampleMap(allSamples, v -> v != null); + Map equalsMap = filterSampleMap(allSamples, v -> filterValue.equals(v)); + Map equalsAnyMap = filterSampleMap(allSamples, v -> filterValue.equals(v) || filterValue2.equals(v)); + Map notEqualsMap = filterSampleMap(allSamples, v -> v == null || !filterValue.equals(v)); + + List cases = List.of( + new GridTCCase("TC→MVTC Grid - Is Blank", Filter.Operator.ISBLANK, new String[0], + Filter.Operator.ARRAY_ISEMPTY.getDisplayValue(), emptyMap), + new GridTCCase("TC→MVTC Grid - Is Not Blank", Filter.Operator.NONBLANK, new String[0], + Filter.Operator.ARRAY_ISNOTEMPTY.getDisplayValue(), nonemptyMap), + new GridTCCase("TC→MVTC Grid - Equals", Filter.Operator.EQUAL, new String[]{filterValue}, + Filter.Operator.ARRAY_CONTAINS_EXACT.getDisplayValue(), equalsMap), + new GridTCCase("TC→MVTC Grid - Equals One Of", Filter.Operator.IN, new String[]{filterValue, filterValue2}, + Filter.Operator.ARRAY_CONTAINS_ANY.getDisplayValue(), equalsAnyMap), + new GridTCCase("TC→MVTC Grid - Does Not Equal", Filter.Operator.NEQ, new String[]{filterValue}, + filterValue, notEqualsMap), + new GridTCCase("TC→MVTC Grid - Equals None Of", Filter.Operator.NOT_IN, new String[]{filterValue}, + filterValue, notEqualsMap) + ); + + log("Creating saved grid views with all TC operators on column '" + COL_MULTITEXTCHOICE + "'."); + List viewNames = cases.stream().map(GridTCCase::viewName).toList(); + for (GridTCCase c : cases) + createTCGridView(c.viewName(), c.op(), c.filterVals()); + + log("Converting '" + COL_MULTITEXTCHOICE + "' field of " + DEFAULT_VIEW_SAMPLE_TYPE + " back to MultiValueTextChoice."); + enableMVTCFieldMultiSelect(true); + + log("Verifying saved grid views after TC → MVTC conversion."); + verifyGridTCCases(cases); + checker().screenShotIfNewError("GridTCtoMVTC_Error"); + + log("Cleaning up TC→MVTC test views."); + cleanupGridViews(beginAtQueryGrid(DEFAULT_VIEW_SAMPLE_TYPE), viewNames); + } + + @Test + public void testCustomGridViewsMVTCtoText() throws Exception + { + Assume.assumeTrue("Multi-choice text fields are only supported on PostgreSQL", MULTI_CHOICE_ENABLED); + goToProjectHome(); + resetDefaultView(getProjectName(), DEFAULT_VIEW_SAMPLE_TYPE, DEFAULT_COLUMNS); + + log("Create expected sample data for MVTC→Text conversion test."); + Map allSamples = createSampleMVTCMap(); + String filterValue = TEXT_MULTI_CHOICE_LIST.get(0); + String filterValue2 = TEXT_MULTI_CHOICE_LIST.get(1); + + Map emptyMap = filterSampleMap(allSamples, v -> v == null); + Map nonemptyMap = filterSampleMap(allSamples, v -> v != null); + Map containsAnyMap = filterSampleMap(allSamples, v -> filterValue.equals(v) || filterValue2.equals(v)); + Map containsExactMap = filterSampleMap(allSamples, v -> filterValue.equals(v)); + Map containsNotEqMap = filterSampleMap(allSamples, v -> v == null || !filterValue.equals(v)); + Map containsNoneMap = filterSampleMap(allSamples, v -> v == null || (!filterValue.equals(v) && !filterValue2.equals(v))); + + List cases = List.of( + new GridMVTCCase("MVTC→Str Grid - Is Empty", Filter.Operator.ARRAY_ISEMPTY, new String[0], + GridConversionResult.kept(Filter.Operator.ISBLANK.getDisplayValue()), emptyMap), + new GridMVTCCase("MVTC→Str Grid - Contains Any", Filter.Operator.ARRAY_CONTAINS_ANY, new String[]{filterValue, filterValue2}, + GridConversionResult.kept(Filter.Operator.IN.getDisplayValue()), containsAnyMap), + new GridMVTCCase("MVTC→Str Grid - Contains All", Filter.Operator.ARRAY_CONTAINS_ALL, new String[]{filterValue, filterValue2}, + GridConversionResult.dropped(), containsAnyMap), + new GridMVTCCase("MVTC→Str Grid - Is Not Empty", Filter.Operator.ARRAY_ISNOTEMPTY, new String[0], + GridConversionResult.kept(Filter.Operator.NONBLANK.getDisplayValue()), nonemptyMap), + new GridMVTCCase("MVTC→Str Grid - Contains Not Exact", Filter.Operator.ARRAY_CONTAINS_NOT_EXACT, new String[]{filterValue}, + GridConversionResult.dropped(), containsNotEqMap), + new GridMVTCCase("MVTC→Str Grid - Contains Exact", Filter.Operator.ARRAY_CONTAINS_EXACT, new String[]{filterValue}, + GridConversionResult.dropped(), containsExactMap), + new GridMVTCCase("MVTC→Str Grid - Contains None", Filter.Operator.ARRAY_CONTAINS_NONE, new String[]{filterValue, filterValue2}, + GridConversionResult.kept(filterValue), containsNoneMap) + ); + + log("Creating saved grid views with all MVTC operators on column '" + COL_MULTITEXTCHOICE + "'."); + List viewNames = cases.stream().map(GridMVTCCase::viewName).toList(); + for (GridMVTCCase c : cases) + createMVTCGridView(c.viewName(), c.op(), c.filterVals()); + + log("Converting '" + COL_MULTITEXTCHOICE + "' field of " + DEFAULT_VIEW_SAMPLE_TYPE + " from MVTC to plain String."); + changeMVTCFieldToText(); + + log("Verifying saved grid views after MVTC → String conversion."); + verifyGridMVTCCases(cases); + checker().screenShotIfNewError("GridMVTCtoStr_Error"); + + log("Restoring field back to MultiValueTextChoice."); + changeTextFieldToMVTC(); + + log("Cleaning up MVTC→String test views."); + cleanupGridViews(beginAtQueryGrid(DEFAULT_VIEW_SAMPLE_TYPE), viewNames); + } + + /** + * Builds a map of sample name → expected MVTC column value for DEFAULT_VIEW_SAMPLE_TYPE. + * Samples with sampleId % 5 == 0 have a {@code null} (empty) value; others get one + * value from {@link #TEXT_MULTI_CHOICE_LIST} determined by the hash of the sample name. + */ + private static Map createSampleMVTCMap() + { + Map map = new LinkedHashMap<>(); + for (int i = 1; i <= DEFAULT_VIEW_SAMPLE_TYPE_SIZE; i++) + { + String name = DEFAULT_VIEW_SAMPLE_PREFIX + i; + String value = (i % 5 == 0) ? null + : TEXT_MULTI_CHOICE_LIST.get(Math.abs(name.hashCode()) % TEXT_MULTI_CHOICE_LIST.size()); + map.put(name, value); + } + return map; + } + + /** + * Returns a sub-map keeping only entries whose value satisfies {@code valuePredicate}. + */ + private static Map filterSampleMap(Map allSamples, + Predicate valuePredicate) + { + // Collectors.toMap rejects null values, so populate the map manually. + Map result = new LinkedHashMap<>(); + allSamples.entrySet().stream() + .filter(e -> valuePredicate.test(e.getValue())) + .forEach(e -> result.put(e.getKey(), e.getValue())); + return result; + } + + /** + * Navigates to DEFAULT_VIEW_SAMPLE_TYPE grid, applies an MVTC-type array filter via the + * grid filter dialog, and saves the result as a personal named view. + */ + private void createMVTCGridView(String viewName, Filter.Operator op, String... vals) + { + QueryGrid grid = beginAtQueryGrid(DEFAULT_VIEW_SAMPLE_TYPE); + GridFilterModal filterDialog = grid.getGridBar().openFilterDialog(); + filterDialog.selectField(COL_MULTITEXTCHOICE); + FilterFacetedPanel facetPanel = filterDialog.selectFacetTab(); + facetPanel.selectArrayFilterOperator(op); + if (vals.length > 0) + facetPanel.checkValues(vals); + filterDialog.confirm(); + grid.saveView(viewName); + } + + /** + * Navigates to DEFAULT_VIEW_SAMPLE_TYPE grid (with TextChoice field), applies a TC filter, + * and saves the result as a personal named view. + * For {@link Filter.Operator#IN}, values are selected via the facet tab (supporting multiple + * values); all other operators use the expression tab. + */ + private void createTCGridView(String viewName, Filter.Operator op, String... vals) + { + QueryGrid grid = beginAtQueryGrid(DEFAULT_VIEW_SAMPLE_TYPE); + GridFilterModal filterDialog = grid.getGridBar().openFilterDialog(); + filterDialog.selectField(COL_MULTITEXTCHOICE); + if (op == Filter.Operator.IN) + { + FilterFacetedPanel filterFacetedPanel = filterDialog.selectFacetTab(); + filterFacetedPanel.uncheckValues("[All]"); + filterFacetedPanel.checkValues(vals); + } + else if (vals.length > 0) + { + filterDialog.selectExpressionTab().setFilter(new FilterExpressionPanel.Expression(op, vals[0])); + } + else + { + filterDialog.selectExpressionTab().setFilter(new FilterExpressionPanel.Expression(op)); + } + filterDialog.confirm(); + + grid.saveView(viewName); + } + + /** + * Toggles 'Allow Multiple Selections' on the COL_MULTITEXTCHOICE field of + * DEFAULT_VIEW_SAMPLE_TYPE. Pass {@code false} for MVTC→TC, {@code true} for TC→MVTC. + */ + private void enableMVTCFieldMultiSelect(boolean enable) + { + UpdateSampleTypePage updatePage = beginAtSampleTypesList(this, getProjectName()) + .goToEditSampleType(DEFAULT_VIEW_SAMPLE_TYPE); + updatePage.getFieldsPanel() + .getField(COL_MULTITEXTCHOICE) + .expand() + .setAllowMultipleSelections(enable); + updatePage.clickSave(); + } + + /** + * Changes the COL_MULTITEXTCHOICE field of DEFAULT_VIEW_SAMPLE_TYPE from MVTC + * to plain String type (MVTC → Text conversion). + */ + private void changeMVTCFieldToText() + { + UpdateSampleTypePage updatePage = beginAtSampleTypesList(this, getProjectName()) + .goToEditSampleType(DEFAULT_VIEW_SAMPLE_TYPE); + updatePage.getFieldsPanel() + .getField(COL_MULTITEXTCHOICE) + .expand() + .setType(FieldDefinition.ColumnType.String, true); + updatePage.clickSave(); + } + + /** + * Restores the COL_MULTITEXTCHOICE field of DEFAULT_VIEW_SAMPLE_TYPE from plain + * String back to MultiValueTextChoice (Text → MVTC restoration). + */ + private void changeTextFieldToMVTC() + { + UpdateSampleTypePage updatePage = beginAtSampleTypesList(this, getProjectName()) + .goToEditSampleType(DEFAULT_VIEW_SAMPLE_TYPE); + updatePage.getFieldsPanel() + .getField(COL_MULTITEXTCHOICE) + .expand() + .setType(FieldDefinition.ColumnType.MultiValueTextChoice, false) + .setTextChoiceValues(TEXT_MULTI_CHOICE_LIST) + .setAllowMultipleSelections(true); + updatePage.clickSave(); + } + + /** + * Verifies all MVTC cases after a type conversion. + * For kept filters: checks the filter pill text and (when row count fits on one page) data. + * For dropped filters: checks that no filter pills are shown. + */ + private void verifyGridMVTCCases(List cases) + { + for (GridMVTCCase c : cases) + { + if (c.conversionResult().isDropped()) + verifyDroppedGridView(c.viewName()); + else + { + QueryGrid grid = verifyGridViewFilter(c.viewName(), c.conversionResult().expectedFilterText()); + if (c.expectedSampleMap().size() <= DEFAULT_PAGE_SIZE) + TestArrayDataUtils.verifyMVTCResults(grid, c.expectedSampleMap(), COL_NAME, COL_MULTITEXTCHOICE, checker()); + } + } + } + + /** + * Verifies all TC→MVTC cases: checks filter pill text and (when small enough) data. + */ + private void verifyGridTCCases(List cases) + { + for (GridTCCase c : cases) + { + QueryGrid grid = verifyGridViewFilter(c.viewName(), c.expectedAfterConversionText()); + if (c.expectedSampleMap().size() <= DEFAULT_PAGE_SIZE) + TestArrayDataUtils.verifyMVTCResults(grid, c.expectedSampleMap(), COL_NAME, COL_MULTITEXTCHOICE, checker()); + } + } + + /** + * Navigates to the DEFAULT_VIEW_SAMPLE_TYPE grid, selects the named view, and verifies + * that at least one filter pill text contains {@code expectedFilterText}. + * + * @return the QueryGrid showing the named view, for further data assertions. + */ + private QueryGrid verifyGridViewFilter(String viewName, String expectedFilterText) + { + QueryGrid grid = beginAtQueryGrid(DEFAULT_VIEW_SAMPLE_TYPE); + grid.selectView(viewName); + List pillTexts = grid.getFilterStatusValuesText(); + checker().withScreenshot().verifyTrue( + "View '" + viewName + "': filter pill should contain '" + expectedFilterText + "'", + pillTexts.stream().anyMatch(pill -> pill.contains(expectedFilterText))); + return grid; + } + + /** + * Verifies that a saved view whose filter was dropped after type conversion shows no + * filter pills (i.e., the grid displays all samples without restriction). + */ + private void verifyDroppedGridView(String viewName) + { + QueryGrid grid = beginAtQueryGrid(DEFAULT_VIEW_SAMPLE_TYPE); + grid.selectView(viewName); + checker().withScreenshot().verifyTrue( + "View '" + viewName + "': dropped filter should not show any filter pills", + grid.getFilterStatusValues().isEmpty()); + } + + /** + * Deletes the named views from the DEFAULT_VIEW_SAMPLE_TYPE grid's Manage Views dialog. + * Views that are no longer present (e.g., already deleted) are silently skipped. + */ + private void cleanupGridViews(QueryGrid grid, List viewNames) + { + ManageViewsDialog dialog = grid.manageViews(); + List existingViews = dialog.getViewNames(); + for (String name : viewNames) + { + if (existingViews.stream().anyMatch(v -> v.equals(name))) + dialog.deleteView(name).confirmDelete(); + } + dialog.dismiss("Done"); + } + /** * Helper to validate the 'Views' menu. * @@ -1578,7 +1991,7 @@ public void testGridViewAuditEvents() throws Exception { goToProjectHome(); - resetDefaultView(VIEW_DIALOG_ST, DEFAULT_COLUMNS); + resetDefaultView(getProjectName(), VIEW_DIALOG_ST, DEFAULT_COLUMNS); // Part A: Verify audit event on Create diff --git a/src/org/labkey/test/util/data/TestArrayDataUtils.java b/src/org/labkey/test/util/data/TestArrayDataUtils.java index deaf7fa81b..2bac3ff78a 100644 --- a/src/org/labkey/test/util/data/TestArrayDataUtils.java +++ b/src/org/labkey/test/util/data/TestArrayDataUtils.java @@ -3,7 +3,10 @@ import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVParser; import org.apache.commons.csv.CSVRecord; +import org.junit.Assert; import org.labkey.remoteapi.query.Filter; +import org.labkey.test.components.ui.grids.QueryGrid; +import org.labkey.test.util.DeferredErrorCollector; import java.io.IOException; import java.io.StringReader; @@ -13,6 +16,8 @@ import java.util.Map; import java.util.stream.Collectors; +import static org.assertj.core.api.Assertions.assertThat; + public class TestArrayDataUtils { @@ -127,4 +132,27 @@ private static boolean isMatch(List actualValues, List searchVal default -> throw new IllegalArgumentException("Invalid filter type " + type); }; } + + /** + * Verifies that the grid contains exactly the expected row IDs and that each row's MVTC column + * value matches the expected value in {@code sampleMVTCMap}. + * Size mismatch is a hard failure; ID and per-row value checks are soft (collected via {@code checker}). + * + * @param idColumn the column label used to identify rows (e.g. "Sample ID" or "Name") + */ + public static void verifyMVTCResults(QueryGrid grid, Map sampleMVTCMap, + String idColumn, String colLabel, DeferredErrorCollector checker) + { + List foundIds = grid.getColumnDataAsText(idColumn); + Assert.assertEquals("grid row count mismatch", sampleMVTCMap.size(), foundIds.size()); + checker.wrapAssertion(() -> assertThat(foundIds) + .as(idColumn + " values in grid") + .containsExactlyInAnyOrderElementsOf(sampleMVTCMap.keySet())); + sampleMVTCMap.forEach((id, expected) -> { + Map rowMap = grid.getRowMapByLabel(idColumn, id); + checker.wrapAssertion(() -> assertThat(rowMap.get(colLabel)) + .as("'%s' value for %s '%s'", colLabel, idColumn, id) + .isEqualTo(expected == null ? "" : expected)); + }); + } } \ No newline at end of file