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..f1d66a4729 100644 --- a/src/org/labkey/test/components/domain/DomainFormPanel.java +++ b/src/org/labkey/test/components/domain/DomainFormPanel.java @@ -236,6 +236,17 @@ else if (validator instanceof FieldDefinition.TextChoiceValidator textChoiceVali throw new IllegalArgumentException("TextChoice fields cannot have additional validators."); } fieldRow.setTextChoiceValues(textChoiceValidator.getValues()); + fieldRow.setAllowMultipleSelections(false); + } + else if (validator instanceof FieldDefinition.MultiValueTextChoiceValidator multiValueTextChoiceValidator) + { + // MultiValueTextChoice is a field type; implemented using a special validator. TextChoice field cannot have other validators. + if (validators.size() > 1) + { + throw new IllegalArgumentException("TextChoice fields cannot have additional validators."); + } + fieldRow.setTextChoiceValues(multiValueTextChoiceValidator.getValues()); + 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..ad279022a0 100644 --- a/src/org/labkey/test/components/ui/grids/EditableGrid.java +++ b/src/org/labkey/test/components/ui/grids/EditableGrid.java @@ -507,11 +507,14 @@ 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); + lookupSelect.clearSelection(); + lookupSelect.open(); for (String _value : values) diff --git a/src/org/labkey/test/components/ui/grids/ResponsiveGrid.java b/src/org/labkey/test/components/ui/grids/ResponsiveGrid.java index 643fd1e033..c50ba05ab2 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,14 @@ 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.CONTAINS_ALL; +import static org.labkey.remoteapi.query.Filter.Operator.CONTAINS_ANY; +import static org.labkey.remoteapi.query.Filter.Operator.CONTAINS_EXACTLY; +import static org.labkey.remoteapi.query.Filter.Operator.CONTAINS_NONE; +import static org.labkey.remoteapi.query.Filter.Operator.DOES_NOT_CONTAIN_EXACTLY; +import static org.labkey.remoteapi.query.Filter.Operator.IN; +import static org.labkey.remoteapi.query.Filter.Operator.IS_EMPTY; +import static org.labkey.remoteapi.query.Filter.Operator.IS_NOT_EMPTY; import static org.labkey.test.WebDriverWrapper.waitFor; public class ResponsiveGrid> extends WebDriverComponent.ElementCache> implements UpdatingComponent @@ -234,18 +243,31 @@ public String filterColumnExpectingError(CharSequence columnIdentifier, Filter.O private GridFilterModal initFilterColumn(CharSequence columnIdentifier, Filter.Operator operator, Object value) { + List listOperators = List.of(IN, CONTAINS_ALL, CONTAINS_ANY, CONTAINS_EXACTLY, CONTAINS_NONE, + DOES_NOT_CONTAIN_EXACTLY); clickColumnMenuItem(columnIdentifier, "Filter...", false); GridFilterModal filterModal = new GridFilterModal(getDriver(), this); if (operator != null) { - if (operator.equals(Filter.Operator.IN) && value instanceof List) + if (listOperators.contains(operator) && value instanceof List) { List values = (List) value; - filterModal.selectFacetTab().selectValue(values.get(0)); - filterModal.selectFacetTab().checkValues(values.toArray(String[]::new)); + FilterFacetedPanel filterPanel = filterModal.selectFacetTab(); + filterPanel.selectValue(values.get(0)); + filterPanel.checkValues(values.toArray(String[]::new)); + if (filterPanel.isFiltersPresented()) + { + filterPanel.selectFilter(operator); + } + } + else if (value == null) + { + filterModal.selectFacetTab().selectFilter(operator); } else + { filterModal.selectExpressionTab().setFilter(new FilterExpressionPanel.Expression(operator, value)); + } } return filterModal; } @@ -385,15 +407,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..d3bd25a609 100644 --- a/src/org/labkey/test/components/ui/search/FilterFacetedPanel.java +++ b/src/org/labkey/test/components/ui/search/FilterFacetedPanel.java @@ -6,7 +6,9 @@ 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.labkey.remoteapi.query.Filter; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.ui.ExpectedConditions; @@ -14,7 +16,9 @@ import java.util.List; import java.util.stream.Collectors; +import static org.labkey.test.WebDriverWrapper.waitFor; import static org.labkey.test.components.html.Input.Input; +import static org.labkey.test.util.samplemanagement.SMTestUtils.isVisible; public class FilterFacetedPanel extends WebDriverComponent { @@ -48,6 +52,23 @@ public void selectValue(String value) elementCache().findCheckboxLabel(value).click(); } + /** + * Check that filter choosing option exists on the page. + */ + public boolean isFiltersPresented() + { + return waitFor(() -> isVisible(elementCache().filterTypeSelects), 1000); + } + + /** + * 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 selectFilter(Filter.Operator operator) + { + elementCache().filterTypeSelects.select(operator.getDisplayValue()); + } + /** * Check single facet value by label to see if it is checked or not. * @param value desired value @@ -123,6 +144,8 @@ protected class ElementCache extends Component.ElementCache { protected final Input filterInput = Input(Locator.id("filter-faceted__typeahead-input"), getDriver()).findWhenNeeded(this); + protected final ReactSelect filterTypeSelects = + 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..70e7f2434c 100644 --- a/src/org/labkey/test/pages/DatasetInsertPage.java +++ b/src/org/labkey/test/pages/DatasetInsertPage.java @@ -82,7 +82,7 @@ private void tryInsert(Map values) { 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) { diff --git a/src/org/labkey/test/pages/query/UpdateQueryRowPage.java b/src/org/labkey/test/pages/query/UpdateQueryRowPage.java index 7c265ebca7..ca383a6f53 100644 --- a/src/org/labkey/test/pages/query/UpdateQueryRowPage.java +++ b/src/org/labkey/test/pages/query/UpdateQueryRowPage.java @@ -99,7 +99,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 { diff --git a/src/org/labkey/test/params/FieldDefinition.java b/src/org/labkey/test/params/FieldDefinition.java index 9f2b5f0900..3f426ef2a7 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.MultiValueTextChoiceValidator(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); /** @@ -1145,6 +1153,49 @@ public List getValues() } + /** + * TextChoice is implemented using a validator, however it is more 'limited' in scope. The user does not name a TextChoice + * validator or add a description or error message. A TextChoice is a lot like a look-up field, but it is not linked + * to an external data source. The user only provides the list of (string) values that the field will display in the dropdown. + * Another difference is that there can only be one TextChoice on a field, whereas you can have multiple validators on a field. + */ + public static class MultiValueTextChoiceValidator extends FieldValidator + { + private final List _values; + + public MultiValueTextChoiceValidator(List values) + { + // The TextChoice validator only has a name and no description or message. + // And the name is generated (not user defined). + super("Text Choice Validator", "", ""); + _values = Collections.unmodifiableList(values); + } + + @Override + protected MultiValueTextChoiceValidator getThis() + { + return this; + } + + @Override + protected String getType() + { + return "TextChoice"; + } + + @Override + protected String getExpression() + { + return EscapeUtil.getTextChoiceValidatorExpression(_values); + } + + public List getValues() + { + return _values; + } + + } + } 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..1b47a050e1 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; @@ -1688,6 +1689,41 @@ 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); + valuesToChoose.forEach(value->{ + insertNewRow.setField(columnName, value); + }); + insertNewRow.submit(); + checker().withScreenshot().verifyEquals("Multi choice value not as expected", String.join(" ", valuesToChoose), table.getDataAsText(0, columnName)); + + UpdateQueryRowPage editRow = table.clickEditRow(0); + valuesToChoose = tcValues.subList(1, 3); + valuesToChoose.forEach(value->{ + editRow.setField(columnName, value); + }); + editRow.submit(); + + // verify the multi choice value is persisted + checker().withScreenshot().verifyEquals("Multi choice value not as expected", String.join(" ", valuesToChoose), 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();