diff --git a/src/org/labkey/test/components/domain/DomainFieldRow.java b/src/org/labkey/test/components/domain/DomainFieldRow.java index b5798c5e8c..dc1ea4f328 100644 --- a/src/org/labkey/test/components/domain/DomainFieldRow.java +++ b/src/org/labkey/test/components/domain/DomainFieldRow.java @@ -767,6 +767,11 @@ 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) + { + elementCache().allowMultipleSelectionsCheckbox.set(allowMultipleSelections); + } + /** * Set the list of allowed values for a TextChoice field. * @@ -1702,6 +1707,10 @@ protected class ElementCache extends WebDriverComponent.ElementCache public final WebElement domainWarningIcon = Locator.tagWithClass("span", "domain-warning-icon") .findWhenNeeded(this); + // text choice field option + public final Checkbox allowMultipleSelectionsCheckbox = new Checkbox(Locator.tagWithClass("input", "domain-text-choice-multi") + .refindWhenNeeded(this).withTimeout(WAIT_FOR_JAVASCRIPT)); + // lookup field options public final Select lookupContainerSelect = SelectWrapper.Select(Locator.name("domainpropertiesrow-lookupContainer")) .findWhenNeeded(this); diff --git a/src/org/labkey/test/components/domain/DomainFormPanel.java b/src/org/labkey/test/components/domain/DomainFormPanel.java index fb4b639f7c..f235c2391f 100644 --- a/src/org/labkey/test/components/domain/DomainFormPanel.java +++ b/src/org/labkey/test/components/domain/DomainFormPanel.java @@ -236,6 +236,10 @@ else if (validator instanceof FieldDefinition.TextChoiceValidator textChoiceVali throw new IllegalArgumentException("TextChoice fields cannot have additional validators."); } fieldRow.setTextChoiceValues(textChoiceValidator.getValues()); + if(fieldDefinition.getType() == FieldDefinition.ColumnType.MultiValueTextChoice) + { + fieldRow.setAllowMultipleSelections(true); + } } else { diff --git a/src/org/labkey/test/components/ui/entities/EntityBulkUpdateDialog.java b/src/org/labkey/test/components/ui/entities/EntityBulkUpdateDialog.java index a5edaae738..7c9309caba 100644 --- a/src/org/labkey/test/components/ui/entities/EntityBulkUpdateDialog.java +++ b/src/org/labkey/test/components/ui/entities/EntityBulkUpdateDialog.java @@ -124,6 +124,19 @@ public EntityBulkUpdateDialog setSelectionField(CharSequence fieldIdentifier, Li return this; } + /** + * Clear the field (fieldIdentifier). + * + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return this component + */ + public EntityBulkUpdateDialog clearSelection(CharSequence fieldIdentifier) + { + FilteringReactSelect reactSelect = enableSelectionField(fieldIdentifier); + reactSelect.clearSelection(); + return this; + } + /** * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) * @param selectValue value to select diff --git a/src/org/labkey/test/components/ui/grids/EditableGrid.java b/src/org/labkey/test/components/ui/grids/EditableGrid.java index 04aba55749..9d08ff935a 100644 --- a/src/org/labkey/test/components/ui/grids/EditableGrid.java +++ b/src/org/labkey/test/components/ui/grids/EditableGrid.java @@ -507,10 +507,11 @@ public WebElement setCellValue(int row, CharSequence columnIdentifier, Object va if (value instanceof List) { + // If this is a list assume that it will need a lookup. List values = (List) value; - ReactSelect lookupSelect = elementCache().lookupSelect(gridCell); + ReactSelect lookupSelect = findAndClearLookup(gridCell); lookupSelect.open(); @@ -588,6 +589,12 @@ else if (value instanceof LocalTime localTime) return gridCell; } + private ReactSelect findAndClearLookup(WebElement gridCell) + { + elementCache().lookupSelect(gridCell).clearSelection(); + return elementCache().lookupSelect(gridCell); + } + public void setEntityData(List >data, List fields) { for (int i = 0; i < data.size(); i++) diff --git a/src/org/labkey/test/components/ui/grids/ResponsiveGrid.java b/src/org/labkey/test/components/ui/grids/ResponsiveGrid.java index 643fd1e033..625e9d4909 100644 --- a/src/org/labkey/test/components/ui/grids/ResponsiveGrid.java +++ b/src/org/labkey/test/components/ui/grids/ResponsiveGrid.java @@ -17,6 +17,7 @@ import org.labkey.test.components.react.ReactCheckBox; import org.labkey.test.components.ui.grids.FieldReferenceManager.FieldReference; import org.labkey.test.components.ui.search.FilterExpressionPanel; +import org.labkey.test.components.ui.search.FilterFacetedPanel; import org.labkey.test.params.FieldKey; import org.labkey.test.util.selenium.WebElementUtils; import org.openqa.selenium.Keys; @@ -40,6 +41,13 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertEquals; +import static org.labkey.remoteapi.query.Filter.Operator.ARRAY_CONTAINS_ALL; +import static org.labkey.remoteapi.query.Filter.Operator.ARRAY_CONTAINS_ANY; +import static org.labkey.remoteapi.query.Filter.Operator.ARRAY_CONTAINS_EXACT; +import static org.labkey.remoteapi.query.Filter.Operator.ARRAY_CONTAINS_NONE; +import static org.labkey.remoteapi.query.Filter.Operator.ARRAY_CONTAINS_NOT_EXACT; +import static org.labkey.remoteapi.query.Filter.Operator.ARRAY_ISEMPTY; +import static org.labkey.remoteapi.query.Filter.Operator.ARRAY_ISNOTEMPTY; import static org.labkey.test.WebDriverWrapper.waitFor; public class ResponsiveGrid> extends WebDriverComponent.ElementCache> implements UpdatingComponent @@ -232,20 +240,33 @@ public String filterColumnExpectingError(CharSequence columnIdentifier, Filter.O return errorMsg; } +private static final List ARRAY_OPERATORS = List.of(ARRAY_CONTAINS_ALL, ARRAY_CONTAINS_ANY, ARRAY_CONTAINS_EXACT, ARRAY_CONTAINS_NONE, + ARRAY_CONTAINS_NOT_EXACT, ARRAY_ISEMPTY, ARRAY_ISNOTEMPTY); + private GridFilterModal initFilterColumn(CharSequence columnIdentifier, Filter.Operator operator, Object value) { clickColumnMenuItem(columnIdentifier, "Filter...", false); GridFilterModal filterModal = new GridFilterModal(getDriver(), this); if (operator != null) { - if (operator.equals(Filter.Operator.IN) && value instanceof List) + if (operator.equals(Filter.Operator.IN) && value instanceof List || ARRAY_OPERATORS.contains(operator)) { - List values = (List) value; - filterModal.selectFacetTab().selectValue(values.get(0)); - filterModal.selectFacetTab().checkValues(values.toArray(String[]::new)); + FilterFacetedPanel filterPanel = filterModal.selectFacetTab(); + if (ARRAY_OPERATORS.contains(operator)) + { + filterPanel.selectArrayFilterOperator(operator); + } + if (value != null) + { + List values = (List) value; + filterPanel.selectValue(values.get(0)); + filterPanel.checkValues(values.toArray(String[]::new)); + } } else + { filterModal.selectExpressionTab().setFilter(new FilterExpressionPanel.Expression(operator, value)); + } } return filterModal; } @@ -385,15 +406,16 @@ public T selectRow(int index, boolean checked) /** * Finds the first row with the specified texts in the specified columns, and sets its checkbox - * @param partialMap key-column (fieldKey, name, or label), value-text in that column - * @param checked the desired checkbox state + * + * @param partialMap key-column (fieldKey, name, or label), value-text in that column + * @param checked the desired checkbox state * @return this grid */ public T selectRow(Map partialMap, boolean checked) { GridRow row = getRow(partialMap); selectRowAndVerifyCheckedCounts(row, checked); - getWrapper().log("Row described by map ["+partialMap+"] selection state set to + ["+row.isSelected()+"]"); + getWrapper().log("Row described by map [" + partialMap + "] selection state set to + [" + row.isSelected() + "]"); return getThis(); } diff --git a/src/org/labkey/test/components/ui/search/FilterFacetedPanel.java b/src/org/labkey/test/components/ui/search/FilterFacetedPanel.java index 90a8c78713..1ffbe27f88 100644 --- a/src/org/labkey/test/components/ui/search/FilterFacetedPanel.java +++ b/src/org/labkey/test/components/ui/search/FilterFacetedPanel.java @@ -1,11 +1,13 @@ package org.labkey.test.components.ui.search; import org.apache.commons.lang3.StringUtils; +import org.labkey.remoteapi.query.Filter; import org.labkey.test.Locator; import org.labkey.test.components.Component; import org.labkey.test.components.WebDriverComponent; import org.labkey.test.components.html.Checkbox; import org.labkey.test.components.html.Input; +import org.labkey.test.components.react.ReactSelect; import org.labkey.test.components.ui.FilterStatusValue; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; @@ -48,6 +50,15 @@ public void selectValue(String value) elementCache().findCheckboxLabel(value).click(); } + /** + * Select a filer by clicking its label. Right now this method relevant only for multi-value text choice. + * @param operator desired filter value + */ + public void selectArrayFilterOperator(Filter.Operator operator) + { + elementCache().arrayFilterOperatorSelect.select(operator.getDisplayValue()); + } + /** * Check single facet value by label to see if it is checked or not. * @param value desired value @@ -123,6 +134,8 @@ protected class ElementCache extends Component.ElementCache { protected final Input filterInput = Input(Locator.id("filter-faceted__typeahead-input"), getDriver()).findWhenNeeded(this); + protected final ReactSelect arrayFilterOperatorSelect = + new ReactSelect.ReactSelectFinder(getDriver()).index(0).findWhenNeeded(this); protected final WebElement checkboxSection = Locator.byClass("labkey-wizard-pills").index(0).refindWhenNeeded(this); protected final Locator.XPathLocator checkboxLabelLoc diff --git a/src/org/labkey/test/pages/DatasetInsertPage.java b/src/org/labkey/test/pages/DatasetInsertPage.java index 4651210bdf..6d61ce56de 100644 --- a/src/org/labkey/test/pages/DatasetInsertPage.java +++ b/src/org/labkey/test/pages/DatasetInsertPage.java @@ -22,6 +22,7 @@ import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; +import java.util.List; import java.util.Map; import static org.labkey.test.util.EscapeUtil.FORM_FIELD_PREFIX; @@ -45,26 +46,26 @@ protected void waitForReady() waitForElement(Locator.tag("*").attributeStartsWith("name", FORM_FIELD_PREFIX)); } - public void insert(Map values) + public void insert(Map values) { tryInsert(values); assertElementNotPresent(Locators.labkeyError); } - public void insert(Map values, boolean b, String s) + public void insert(Map values, boolean b, String s) { tryInsert(values); assertElementNotPresent(Locators.labkeyError); } - public void insertExpectingError(Map values) + public void insertExpectingError(Map values) { insertExpectingError(values, null); } - public void insertExpectingError(Map values, String errorMsg) + public void insertExpectingError(Map values, String errorMsg) { tryInsert(values); @@ -78,20 +79,20 @@ public void insertExpectingError(Map values, String errorMsg) } } - private void tryInsert(Map values) + private void tryInsert(Map values) { - for (Map.Entry entry : values.entrySet()) + for (Map.Entry entry : values.entrySet()) { - WebElement fieldInput = Locator.name(EscapeUtil.getFormFieldName(entry.getKey())).findElement(getDriver()); + WebElement fieldInput = Locator.tag("*").attributeEndsWith("name", EscapeUtil.getFormFieldName(entry.getKey())).findElement(getDriver()); String type = fieldInput.getAttribute("type"); switch (type) { case "text": case "file": - setFormElement(fieldInput, entry.getValue()); + setFormElement(fieldInput, entry.getValue().toString()); break; case "checkbox": - if (Boolean.valueOf(entry.getValue())) + if (Boolean.valueOf(entry.getValue().toString())) checkCheckbox(fieldInput); else uncheckCheckbox(fieldInput); @@ -101,10 +102,19 @@ private void tryInsert(Map values) switch (tag) { case "textarea": - setFormElementJS(fieldInput, entry.getValue()); + setFormElementJS(fieldInput, entry.getValue().toString()); break; case "select": - selectOptionByText(fieldInput, entry.getValue()); + if (entry.getValue() instanceof List) + { + List options = (List) entry.getValue(); + for (String option : options) + { + selectOptionByText(fieldInput, option); + } + break; + } + selectOptionByText(fieldInput, entry.getValue().toString()); break; default: throw new IllegalArgumentException("Update " + getClass().getSimpleName() + "#insert() to support field: " + entry.getKey() + ", tag = " + tag + ", type = " + type); diff --git a/src/org/labkey/test/pages/query/UpdateQueryRowPage.java b/src/org/labkey/test/pages/query/UpdateQueryRowPage.java index 7c265ebca7..98b87d52bf 100644 --- a/src/org/labkey/test/pages/query/UpdateQueryRowPage.java +++ b/src/org/labkey/test/pages/query/UpdateQueryRowPage.java @@ -11,13 +11,16 @@ import org.labkey.test.components.html.Checkbox; import org.labkey.test.components.html.Input; import org.labkey.test.components.html.OptionSelect; +import org.labkey.test.components.html.SelectWrapper; import org.labkey.test.pages.LabKeyPage; import org.labkey.test.util.EscapeUtil; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.ui.Select; import java.io.File; import java.util.HashMap; +import java.util.List; import java.util.Map; public class UpdateQueryRowPage extends LabKeyPage @@ -52,7 +55,7 @@ public static UpdateQueryRowPage beginAt(WebDriverWrapper webDriverWrapper, Stri public static UpdateQueryRowPage beginAtInsertRowPage(WebDriverWrapper webDriverWrapper, String containerPath, String schemaName, String queryName) { webDriverWrapper.beginAt(WebTestHelper.buildURL("query", containerPath, "insertQueryRow", - Map.of("schemaName", schemaName, "query.queryName", queryName))); + Map.of("schemaName", schemaName, "query.queryName", queryName))); return new UpdateQueryRowPage(webDriverWrapper.getDriver()); } @@ -87,6 +90,10 @@ else if (value instanceof File f) { setField(entry.getKey(), f); } + else if (value instanceof List l) + { + setField(entry.getKey(), l); + } else { throw new IllegalArgumentException("Unsupported value type for '" + entry.getKey() + "': " + value.getClass().getName()); @@ -99,7 +106,7 @@ public UpdateQueryRowPage setField(String fieldName, String value) WebElement field = elementCache().findField(fieldName); if (field.getTagName().equals("select")) { - setField(fieldName, OptionSelect.SelectOption.textOption(value)); + selectOptionByText(field, value); } else { @@ -108,6 +115,14 @@ public UpdateQueryRowPage setField(String fieldName, String value) return this; } + public UpdateQueryRowPage setField(String fieldName, List values) + { + Select field = elementCache().getMultiChoiceSelect(fieldName); + field.deselectAll(); + values.forEach(field::selectByVisibleText); + return this; + } + public UpdateQueryRowPage setField(String fieldName, Boolean value) { new Checkbox(elementCache().findField(fieldName)).set(value); @@ -136,12 +151,6 @@ public UpdateQueryRowPage setField(String fieldName, OptionSelect.SelectOption o return this; } - public UpdateQueryRowPage setMultiValueField(String fieldName, OptionSelect.SelectOption option) - { - new OptionSelect<>(elementCache().findField(fieldName, true)).selectOption(option); - return this; - } - public String getTextInputValue(String fieldName) { var input = new Input(elementCache().findField(fieldName), getDriver()); @@ -203,5 +212,11 @@ WebElement findField(String name) } final WebElement submitButton = Locator.lkButton("Submit").findWhenNeeded(this); + + Select getMultiChoiceSelect(String name) + { + return SelectWrapper.Select(Locator.name(EscapeUtil.getFormFieldName(name, true))) + .find(getDriver()); + } } } diff --git a/src/org/labkey/test/params/FieldDefinition.java b/src/org/labkey/test/params/FieldDefinition.java index 9f2b5f0900..d679b19542 100644 --- a/src/org/labkey/test/params/FieldDefinition.java +++ b/src/org/labkey/test/params/FieldDefinition.java @@ -475,6 +475,13 @@ public FieldDefinition setTextChoiceValues(List values) return this; } + public FieldDefinition setMultiChoiceValues(List values) + { + Assert.assertEquals("Invalid field type for text choice values.", ColumnType.MultiValueTextChoice, getType()); + setValidators(List.of(new FieldDefinition.TextChoiceValidator(values))); + return this; + } + public ExpSchema.DerivationDataScopeType getAliquotOption() { return _aliquotOption; @@ -611,6 +618,7 @@ public boolean isMeasureByDefault() ColumnType Sample = new ColumnTypeImpl("Sample", "int", "http://www.labkey.org/exp/xml#sample", new IntLookup( "exp", "Materials")); ColumnType Barcode = new ColumnTypeImpl("Unique ID", "string", "http://www.labkey.org/types#storageUniqueId", null); ColumnType TextChoice = new ColumnTypeImpl("Text Choice", "string", "http://www.labkey.org/types#textChoice", null); + ColumnType MultiValueTextChoice = new ColumnTypeImpl("Text Choice", "string", "http://cpas.fhcrc.org/exp/xml#multiChoice", null); ColumnType SMILES = new ColumnTypeImpl("SMILES", "string", "http://www.labkey.org/exp/xml#smiles", null); ColumnType Calculation = new ColumnTypeImpl("Calculation", null, "http://www.labkey.org/exp/xml#calculated", null); /** @@ -1144,7 +1152,6 @@ public List getValues() } } - } class ColumnTypeImpl implements FieldDefinition.ColumnType diff --git a/src/org/labkey/test/tests/list/ListTest.java b/src/org/labkey/test/tests/list/ListTest.java index 41f8992f59..e581037208 100644 --- a/src/org/labkey/test/tests/list/ListTest.java +++ b/src/org/labkey/test/tests/list/ListTest.java @@ -18,6 +18,7 @@ import org.hamcrest.CoreMatchers; import org.hamcrest.MatcherAssert; +import org.junit.Assume; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; @@ -84,6 +85,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -1688,6 +1690,42 @@ public void testAutoIncrementKeyEncoded() _listHelper.deleteList(); } + @Test + public void testMultiChoiceValues() + { + Assume.assumeTrue("Multi-choice text fields are only supported on PostgreSQL", WebTestHelper.getDatabaseType() == WebTestHelper.DatabaseType.PostgreSQL); + // setup a list with an auto-increment key and multi text choice field + String encodedListName = TestDataGenerator.randomDomainName("multiChoiceList", DomainUtils.DomainKind.IntList); + String keyName = TestDataGenerator.randomFieldName("'>'"); + String columnName = TestDataGenerator.randomFieldName("MultiChoiceField"); + List tcValues = List.of("~`!@#$%^&*()_+=[]{}\\|';:\"<>?,./", "1", "2"); + _listHelper.createList(PROJECT_VERIFY, encodedListName, keyName, col(columnName, ColumnType.MultiValueTextChoice) + .setMultiChoiceValues(tcValues)); + _listHelper.goToList(encodedListName); + + DataRegionTable table = new DataRegionTable("query", getDriver()); + UpdateQueryRowPage insertNewRow = table.clickInsertNewRow(); + List valuesToChoose = tcValues.subList(1, 3); + insertNewRow.setField(columnName, valuesToChoose); + insertNewRow.submit(); + String expectedList = valuesToChoose.stream() + .sorted() + .collect(Collectors.joining(" ")); + checker().withScreenshot().verifyEquals("Multi choice value not as expected", expectedList, table.getDataAsText(0, columnName)); + + UpdateQueryRowPage editRow = table.clickEditRow(0); + valuesToChoose = tcValues.subList(1, 3); + editRow.setField(columnName, valuesToChoose); + editRow.submit(); + expectedList = valuesToChoose.stream() + .sorted() + .collect(Collectors.joining(" ")); + // verify the multi choice value is persisted + checker().withScreenshot().verifyEquals("Multi choice value not as expected", expectedList, table.getDataAsText(0, columnName)); + + _listHelper.deleteList(); + } + private List getQueryFormFieldNames() { return Locator.tag("input").attributeStartsWith("name", "quf_") diff --git a/src/org/labkey/test/util/TestDataGenerator.java b/src/org/labkey/test/util/TestDataGenerator.java index 854b2d2952..8b9431a485 100644 --- a/src/org/labkey/test/util/TestDataGenerator.java +++ b/src/org/labkey/test/util/TestDataGenerator.java @@ -60,6 +60,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Random; import java.util.Set; import java.util.concurrent.ThreadLocalRandom; import java.util.function.Function; @@ -956,6 +957,12 @@ public ImportDataResponse importRows(Connection cn, List> ro return getQueryHelper(cn).importData(TestDataUtils.stringFromRows(TestDataUtils.rowListsFromMaps(rows)), lookupByAlternateKey); } + public static List shuffleSelect(List allFields) + { + int randomSize = new Random().nextInt(allFields.size()) + 1; + return shuffleSelect(allFields, randomSize); + } + public static List shuffleSelect(List allFields, int selectCount) { List shuffled = new ArrayList<>(allFields); diff --git a/src/org/labkey/test/util/data/TestDataUtils.java b/src/org/labkey/test/util/data/TestDataUtils.java index 3eb899cb29..b7ed252c1a 100644 --- a/src/org/labkey/test/util/data/TestDataUtils.java +++ b/src/org/labkey/test/util/data/TestDataUtils.java @@ -24,6 +24,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.Reader; +import java.io.StringReader; import java.io.StringWriter; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -577,6 +578,20 @@ public static List> readRowsFromFile(File file, CSVFormat format) t } } + public static List parseMultiValueText(String multiValueString) throws IOException + { + CSVFormat format = CSVFormat.RFC4180.builder() + .setIgnoreSurroundingSpaces(true).get(); + try (CSVParser parser = format.parse(new StringReader(multiValueString))) + { + List records = parser.getRecords(); + List> list = records.stream().map(CSVRecord::toList).toList(); + if (list.size() != 1) + throw new IllegalArgumentException("Invalid multi-value text string: " + multiValueString); + return list.getFirst(); + } + } + public static String stringFromRows(List> rows, CSVFormat format) { StringWriter stringWriter = new StringWriter();