diff --git a/src/org/labkey/test/BaseWebDriverTest.java b/src/org/labkey/test/BaseWebDriverTest.java index cc9345dbd9..85887514a5 100644 --- a/src/org/labkey/test/BaseWebDriverTest.java +++ b/src/org/labkey/test/BaseWebDriverTest.java @@ -40,7 +40,7 @@ import org.junit.runners.model.MultipleFailureException; import org.junit.runners.model.Statement; import org.junit.runners.model.TestTimedOutException; -import org.labkey.api.query.QueryKey; +import org.labkey.api.query.FieldKey; import org.labkey.junit.rules.TestWatcher; import org.labkey.remoteapi.CommandException; import org.labkey.remoteapi.CommandResponse; @@ -218,7 +218,7 @@ public abstract class BaseWebDriverTest extends LabKeySiteWrapper implements Cle public static final double DELTA = 10E-10; - public static final String[] ILLEGAL_QUERY_KEY_CHARACTERS = QueryKey.ILLEGAL; + public static final String[] ILLEGAL_QUERY_KEY_CHARACTERS = FieldKey.ILLEGAL; public static final String ALL_ILLEGAL_QUERY_KEY_CHARACTERS = StringUtils.join(ILLEGAL_QUERY_KEY_CHARACTERS, ""); // See TSVWriter.shouldQuote. Generally we are not able to use the tab and new line characters when creating field names in the UI, but including here for completeness public static final String[] TRICKY_IMPORT_FIELD_CHARACTERS = {"\\", "\"", "\\t", ",", "\\n", "\\r"}; diff --git a/src/org/labkey/test/TestFileUtils.java b/src/org/labkey/test/TestFileUtils.java index fe93c8bfaa..fcbed755cd 100644 --- a/src/org/labkey/test/TestFileUtils.java +++ b/src/org/labkey/test/TestFileUtils.java @@ -48,6 +48,7 @@ import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.BufferedReader; +import java.io.BufferedWriter; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; @@ -55,6 +56,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.io.OutputStreamWriter; import java.io.Writer; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; @@ -470,7 +472,12 @@ public static File writeTempFile(String name, String contents) throws IOExceptio */ public static File writeFile(File file, String contents) throws IOException { - try (Writer writer = PrintWriters.getPrintWriter(file)) + return writeFile(file, contents, false); + } + + public static File writeFile(File file, String contents, boolean append) throws IOException + { + try (Writer writer = new OutputStreamWriter(new FileOutputStream(file, append), StandardCharsets.UTF_8)) { writer.write(contents); return file; @@ -578,10 +585,10 @@ private static List unTar(final File inputFile, final File outputDir) thro { final List untaredFiles = new ArrayList<>(); try (InputStream is = new FileInputStream(inputFile); - TarArchiveInputStream inputStream = (TarArchiveInputStream) new ArchiveStreamFactory().createArchiveInputStream("tar", is)) + TarArchiveInputStream inputStream = new ArchiveStreamFactory().createArchiveInputStream("tar", is)) { TarArchiveEntry entry; - while ((entry = (TarArchiveEntry) inputStream.getNextEntry()) != null) + while ((entry = inputStream.getNextEntry()) != null) { final File outputFile = new File(outputDir, entry.getName()); if (entry.isDirectory()) @@ -598,7 +605,7 @@ private static List unTar(final File inputFile, final File outputDir) thro { try (OutputStream outputFileStream = new FileOutputStream(outputFile)) { - org.apache.commons.compress.utils.IOUtils.copy(inputStream, outputFileStream); + IOUtils.copy(inputStream, outputFileStream); } } untaredFiles.add(outputFile); diff --git a/src/org/labkey/test/WebDriverWrapper.java b/src/org/labkey/test/WebDriverWrapper.java index e894e92d18..f5dbd43307 100644 --- a/src/org/labkey/test/WebDriverWrapper.java +++ b/src/org/labkey/test/WebDriverWrapper.java @@ -3434,7 +3434,7 @@ public void setFormElement(Locator l, String text) */ public void setFormElement(WebElement el, String text) { - String inputType = el.getAttribute("type"); + String inputType = el.getDomAttribute("type"); if ("file".equals(inputType)) { diff --git a/src/org/labkey/test/components/ManageSampleStatusesPanel.java b/src/org/labkey/test/components/ManageSampleStatusesPanel.java index 31d889a0a4..3777ad8495 100644 --- a/src/org/labkey/test/components/ManageSampleStatusesPanel.java +++ b/src/org/labkey/test/components/ManageSampleStatusesPanel.java @@ -158,7 +158,7 @@ public boolean isLocked() public List getStatusNames() { return elementCache().statusItems - .findElements(this) + .waitForElements(this, 2_000) .stream() .map(WebElement::getText) .collect(Collectors.toList()); diff --git a/src/org/labkey/test/components/react/Tabs.java b/src/org/labkey/test/components/react/Tabs.java index 3336a8c9c2..2d0e5c51c5 100644 --- a/src/org/labkey/test/components/react/Tabs.java +++ b/src/org/labkey/test/components/react/Tabs.java @@ -53,9 +53,14 @@ public WebDriver getDriver() return _driver; } + public WebElement findTab(String tabText) + { + return elementCache().findTab(tabText); + } + public WebElement findPanelForTab(String tabText) { - return elementCache().findTabPanel(tabText); + return elementCache().findTabPanel(elementCache().findTab(tabText)); } public WebElement selectTab(String tabText) @@ -63,14 +68,19 @@ public WebElement selectTab(String tabText) WebElement tab = elementCache().findTab(tabText); getWrapper().scrollIntoView(tab); tab.click(); - WebElement panel = findPanelForTab(tabText); + WebElement panel = elementCache().findTabPanel(tab); getWrapper().shortWait().until(ExpectedConditions.visibilityOf(panel)); return panel; } + public WebElement findPanelForActiveTab() + { + return elementCache().findTabPanel(elementCache().findSelectedTab()); + } + public boolean isTabSelected(String tabText) { - return Boolean.valueOf(elementCache().findTab(tabText).getAttribute("aria-selected")); + return Boolean.valueOf(elementCache().findTab(tabText).getDomAttribute("aria-selected")); } public List getTabText() @@ -80,6 +90,16 @@ public List getTabText() .stream().map(WebElement::getText).toList(); } + public String getSelectedTabText() + { + return elementCache().findSelectedTab().getText(); + } + + public String getSelectedTabKey() + { + return elementCache().findSelectedTab().getDomAttribute("data-event-key"); + } + @Override protected ElementCache newElementCache() { @@ -102,6 +122,11 @@ public ElementCache() } } + protected WebElement findSelectedTab() + { + return tabLoc.withAttribute("aria-selected", "true").findElement(this); + } + List findAllTabs() { if (tabs.isEmpty()) @@ -125,7 +150,7 @@ WebElement findTab(String tabText) catch (NoSuchElementException ex) { throw new NoSuchElementException(String.format("'%s' not among available tabs: %s", - tabText, getWrapper().getTexts(findAllTabs())), ex); + tabText, getWrapper().getTexts(findAllTabs())), ex); } tabMap.put(tabText, tabEl); } @@ -133,9 +158,9 @@ WebElement findTab(String tabText) } // Tab panels can be updated and changed when flipping between tabs. Don't persist the panel element find it each time. - WebElement findTabPanel(String tabText) + WebElement findTabPanel(WebElement tabElement) { - String panelId = findTab(tabText).getAttribute("aria-controls"); + String panelId = tabElement.getDomAttribute("aria-controls"); WebElement panelEl; try { @@ -143,7 +168,7 @@ WebElement findTabPanel(String tabText) } catch (NoSuchElementException ex) { - throw new NoSuchElementException("Panel not found for tab : " + tabText, ex); + throw new NoSuchElementException("Panel not found for tab : " + tabElement.getText(), ex); } return panelEl; diff --git a/src/org/labkey/test/components/ui/entities/EntityBulkInsertDialog.java b/src/org/labkey/test/components/ui/entities/EntityBulkInsertDialog.java index d2bdff4ce9..49b7c87ad3 100644 --- a/src/org/labkey/test/components/ui/entities/EntityBulkInsertDialog.java +++ b/src/org/labkey/test/components/ui/entities/EntityBulkInsertDialog.java @@ -1,6 +1,5 @@ package org.labkey.test.components.ui.entities; -import org.jetbrains.annotations.Nullable; import org.labkey.test.BootstrapLocators; import org.labkey.test.Locator; import org.labkey.test.WebDriverWrapper; @@ -10,18 +9,24 @@ import org.labkey.test.components.html.RadioButton; import org.labkey.test.components.react.FilteringReactSelect; import org.labkey.test.components.react.ReactDateTimePicker; +import org.labkey.test.components.ui.files.FileAttachmentContainer; import org.labkey.test.params.FieldDefinition; -import org.labkey.test.util.EscapeUtil; +import org.labkey.test.params.FieldKey; import org.openqa.selenium.StaleElementReferenceException; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.ui.ExpectedConditions; +import java.io.File; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +/** + * `fieldIdentifier` arguments accept field names or {@link FieldKey}s + */ public class EntityBulkInsertDialog extends ModalDialog { public EntityBulkInsertDialog(WebDriver driver) @@ -29,7 +34,7 @@ public EntityBulkInsertDialog(WebDriver driver) this(new ModalDialogFinder(driver).withTitle("Bulk Add")); } - private EntityBulkInsertDialog(ModalDialogFinder finder) + protected EntityBulkInsertDialog(ModalDialogFinder finder) { super(finder); } @@ -91,11 +96,11 @@ public String getCreationTypeSelected() { if(elementCache().derivativesOption.isChecked()) { - option = elementCache().derivativesOption.getComponentElement().getAttribute("value"); + option = elementCache().derivativesOption.getComponentElement().getDomAttribute("value"); } else { - option = elementCache().poolOption.getComponentElement().getAttribute("value"); + option = elementCache().poolOption.getComponentElement().getDomAttribute("value"); } } @@ -104,12 +109,7 @@ public String getCreationTypeSelected() public EntityBulkInsertDialog setQuantity(int quantity) { - return setQuantity(Integer.toString(quantity)); - } - - public EntityBulkInsertDialog setQuantity(String quantity) - { - getWrapper().setFormElement(elementCache().quantity, quantity); + getWrapper().setFormElement(elementCache().quantity, Integer.toString(quantity)); return this; } @@ -161,83 +161,129 @@ public String getDescription() return getWrapper().getFormElement(elementCache().description); } - public EntityBulkInsertDialog setTextField(String fieldKey, String value) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @param value value to set + * @return this component + */ + public EntityBulkInsertDialog setTextArea(CharSequence fieldIdentifier, String value) + { + elementCache().textArea(fieldIdentifier).set(value); + return this; + } + + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return current value of the specified field + */ + public String getTextArea(CharSequence fieldIdentifier) + { + return elementCache().textArea(fieldIdentifier).get(); + } + + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @param value value to set + * @return this component + */ + public EntityBulkInsertDialog setTextField(CharSequence fieldIdentifier, String value) { - elementCache().textInput(fieldKey).set(value); + elementCache().textInput(fieldIdentifier).set(value); return this; } - public String getTextField(String fieldKey) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return current value of the specified field + */ + public String getTextField(CharSequence fieldIdentifier) { - return elementCache().textInput(fieldKey).get(); + return elementCache().textInput(fieldIdentifier).get(); } - public EntityBulkInsertDialog setNumericField(String fieldKey, String value) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @param value value to set + * @return this component + */ + public EntityBulkInsertDialog setNumericField(CharSequence fieldIdentifier, String value) { - elementCache().numericInput(fieldKey).set(value); + elementCache().numericInput(fieldIdentifier).set(value); return this; } - public String getNumericField(String fieldKey) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return current value of the specified field + */ + public String getNumericField(CharSequence fieldIdentifier) { - return elementCache().numericInput(fieldKey).get(); + return elementCache().numericInput(fieldIdentifier).get(); } - public EntityBulkInsertDialog setSelectionField(String fieldCaption, List selectValues) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @param selectValues values to select + * @return this component + */ + public EntityBulkInsertDialog setSelectionField(CharSequence fieldIdentifier, List selectValues) { - FilteringReactSelect reactSelect = elementCache().selectionField(fieldCaption); + FilteringReactSelect reactSelect = elementCache().selectionField(fieldIdentifier); selectValues.forEach(reactSelect::filterSelect); return this; } - public List getSelectionField(String fieldCaption) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return current value of the specified field + */ + public List getSelectionField(CharSequence fieldIdentifier) { - FilteringReactSelect reactSelect = elementCache().selectionField(fieldCaption); + FilteringReactSelect reactSelect = elementCache().selectionField(fieldIdentifier); return reactSelect.getSelections(); } /** * Clear the value(s) from a field that is a drop down selection field. * - * @param fieldCaption Caption for the field. + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) * @return This insert dialog. */ - public EntityBulkInsertDialog clearSelectionField(String fieldCaption) + public EntityBulkInsertDialog clearSelectionField(CharSequence fieldIdentifier) { - FilteringReactSelect reactSelect = elementCache().selectionField(fieldCaption); + FilteringReactSelect reactSelect = elementCache().selectionField(fieldIdentifier); reactSelect.clearSelection(); return this; } - public EntityBulkInsertDialog setFieldWithId(String id, String value) - { - getWrapper().setFormElement(Locator.tagWithId("input", id), value); - return this; - } - - public String getFieldWithId(String id) + // For use when the field is of an unknown type, as can occur in fuzz tests + public void setValue(FieldDefinition field, Object newValue) { - return getWrapper().getFormElement(Locator.tagWithId("input", id)); + if (field.getType() == FieldDefinition.ColumnType.TextChoice || field.getLookup() != null) + setSelectionField(field.getName(), newValue instanceof String ? List.of((String) newValue) : (List) newValue); + else if (field.getType() == FieldDefinition.ColumnType.Integer || field.getType() == FieldDefinition.ColumnType.Decimal || field.getType() == FieldDefinition.ColumnType.Double) + setNumericField(field.getName(), String.valueOf(newValue)); + else if (field.getType() == FieldDefinition.ColumnType.Date || field.getType() == FieldDefinition.ColumnType.DateAndTime || field.getType() == FieldDefinition.ColumnType.Time) + setDateTimeField(field.getName(), newValue); + else if (field.getType() == FieldDefinition.ColumnType.Boolean) + setBooleanField(field.getName(), (Boolean) newValue); + else if (field.getType() == FieldDefinition.ColumnType.MultiLine) + setTextArea(field.getName(), (String) newValue); + else if (field.getType() == FieldDefinition.ColumnType.File) + attachFile(field.getName(), (File) newValue); + else + setTextField(field.getName(), (String) newValue); } public void setInsertFieldValues(List fields, Map data) { for (FieldDefinition field : fields) { - String fieldKey = EscapeUtil.fieldKeyEncodePart(field.getName()); - Object value = data.get(field.getLabel() != null ? field.getLabel() : FieldDefinition.labelFromName(field.getName())); + Object value = data.get(field.getEffectiveLabel()); if (value == null) continue; - if (field.getType() == FieldDefinition.ColumnType.Boolean) - setBooleanField(fieldKey, (Boolean) value); - else if (field.getType() == FieldDefinition.ColumnType.Integer || field.getType() == FieldDefinition.ColumnType.Decimal || field.getType() == FieldDefinition.ColumnType.Double) - setNumericField(fieldKey, String.valueOf(value)); - else if (field.getType() == FieldDefinition.ColumnType.Date || field.getType() == FieldDefinition.ColumnType.DateAndTime || field.getType() == FieldDefinition.ColumnType.Time) - setDateTimeField(fieldKey, value); - else if (field.getType() == FieldDefinition.ColumnType.TextChoice) - setSelectionField(field.getLabel(), (List) value); - else - setTextField(fieldKey, (String) value); + + setValue(field, value); } } @@ -246,32 +292,66 @@ else if (field.getType() == FieldDefinition.ColumnType.TextChoice) * object to use the picker to set the field. If a text value is passed in it is used as a literal and jut typed * into the textbox. * - * @param fieldKey Field to update. + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) * @param dateTime A LocalDateTime, LocalDate, LocalTime or String. * @return A reference to this page. */ - public EntityBulkInsertDialog setDateTimeField(String fieldKey, Object dateTime) + public EntityBulkInsertDialog setDateTimeField(CharSequence fieldIdentifier, Object dateTime) { - ReactDateTimePicker dateTimePicker = elementCache().dateInput(fieldKey); + ReactDateTimePicker dateTimePicker = elementCache().dateInput(fieldIdentifier); dateTimePicker.select(dateTime); return this; } - public String getDateTimeField(String fieldKey) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return current value of the specified field + */ + public String getDateTimeField(CharSequence fieldIdentifier) { - return elementCache().dateInput(fieldKey).get(); + return elementCache().dateInput(fieldIdentifier).get(); } - public EntityBulkInsertDialog setBooleanField(String fieldKey, boolean checked) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @param checked value to set + * @return this component + */ + public EntityBulkInsertDialog setBooleanField(CharSequence fieldIdentifier, boolean checked) { - Checkbox box = elementCache().checkBox(fieldKey); + Checkbox box = elementCache().checkBox(fieldIdentifier); box.set(checked); return this; } - public boolean getBooleanField(String fieldKey) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return current value of the specified field + */ + public boolean getBooleanField(CharSequence fieldIdentifier) + { + return elementCache().checkBox(fieldIdentifier).get(); + } + + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @param file file to attach + * @return this component + */ + public EntityBulkInsertDialog attachFile(CharSequence fieldIdentifier, File file) { - return elementCache().checkBox(fieldKey).get(); + elementCache().fileUploadField(fieldIdentifier).attachFile(file); + return this; + } + + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return this component + */ + public EntityBulkInsertDialog removeFile(CharSequence fieldIdentifier) + { + elementCache().fileUploadField(fieldIdentifier).removeFile(); + return this; } /** @@ -360,6 +440,16 @@ protected void waitForReady() getWrapper().shortWait().until(ExpectedConditions.elementToBeClickable( elementCache().addRowsButton )); } + /** + * File upload fields append "-fileUpload" to the field's fieldKey + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return FieldKey with expected suffix + */ + public static FieldKey fileUploadFieldKey(CharSequence fieldIdentifier) + { + return FieldKey.fromFieldKey(FieldKey.fromName(fieldIdentifier) + "-fileUpload"); + } + @Override protected ElementCache newElementCache() { @@ -374,42 +464,57 @@ protected ElementCache elementCache() protected class ElementCache extends ModalDialog.ElementCache { - public Locator validationMessage = Locator.tagWithClass("span", "validation-message"); + public final Locator validationMessage = Locator.tagWithClass("span", "validation-message"); + + private final Map _rows = new HashMap<>(); - public WebElement formRow(String fieldKey) + public WebElement formRow(CharSequence fieldIdentifier) { - return Locator.tagWithClass("div", "row") - .withChild(Locator.tagWithAttribute("label", "for", fieldKey)) - .findElement(this); + String fieldKey = FieldKey.fromName(fieldIdentifier).toString(); + return _rows.computeIfAbsent(fieldKey, fk -> + Locator.tagWithClass("div", "row") + // TODO: Shouldn't need to be case-insensitive. Parent/source lookups have weird casing + .withChild(Locator.tagWithAttributeIgnoreCase("label", "for", fieldKey)) + .findElement(this)); } - public FilteringReactSelect selectionField(String fieldCaption) + public FilteringReactSelect selectionField(CharSequence fieldIdentifier) { - return FilteringReactSelect.finder(getDriver()).followingLabelWithSpan(fieldCaption).find(this); + return new FilteringReactSelect(formRow(fieldIdentifier), getDriver()); } - public Checkbox checkBox(String fieldKey) + public Checkbox checkBox(CharSequence fieldIdentifier) { - WebElement row = elementCache().formRow(fieldKey); + WebElement row = formRow(fieldIdentifier); return new Checkbox(checkBoxLoc.findElement(row)); } - public Input textInput(String fieldKey) + public Input textInput(CharSequence fieldIdentifier) { - WebElement inputEl = textInputLoc.findElement(elementCache().formRow(fieldKey)); + WebElement inputEl = textInputLoc.findElement(formRow(fieldIdentifier)); return new Input(inputEl, getDriver()); } - public Input numericInput(String fieldKey) + public Input textArea(CharSequence fieldIdentifier) { - WebElement inputEl = numberInputLoc.findElement(formRow(fieldKey)); + WebElement inputEl = Locator.tag("textarea").findElement(formRow(fieldIdentifier)); return new Input(inputEl, getDriver()); } - public ReactDateTimePicker dateInput(String fieldKey) + public Input numericInput(CharSequence fieldIdentifier) + { + WebElement inputEl = numberInputLoc.findElement(formRow(fieldIdentifier)); + return new Input(inputEl, getDriver()); + } + + public ReactDateTimePicker dateInput(CharSequence fieldIdentifier) + { + return new ReactDateTimePicker.ReactDateTimeInputFinder(getDriver()).find(formRow(fieldIdentifier)); + } + + public FileAttachmentContainer fileUploadField(CharSequence fieldIdentifier) { - return new ReactDateTimePicker.ReactDateTimeInputFinder(getDriver()) - .withInputId(fieldKey).find(formRow(fieldKey)); + return new FileAttachmentContainer(formRow(fileUploadFieldKey(fieldIdentifier)), getDriver()); } public List fieldLabels() diff --git a/src/org/labkey/test/components/ui/entities/EntityBulkUpdateDialog.java b/src/org/labkey/test/components/ui/entities/EntityBulkUpdateDialog.java index 090c8207f5..14716f277d 100644 --- a/src/org/labkey/test/components/ui/entities/EntityBulkUpdateDialog.java +++ b/src/org/labkey/test/components/ui/entities/EntityBulkUpdateDialog.java @@ -1,5 +1,6 @@ package org.labkey.test.components.ui.entities; +import org.jetbrains.annotations.NotNull; import org.labkey.remoteapi.CommandException; import org.labkey.test.BootstrapLocators; import org.labkey.test.Locator; @@ -15,20 +16,22 @@ import org.labkey.test.components.react.ToggleButton; import org.labkey.test.components.ui.files.FileAttachmentContainer; import org.labkey.test.params.FieldDefinition; +import org.labkey.test.params.FieldKey; import org.labkey.test.util.AuditLogHelper; -import org.labkey.test.util.EscapeUtil; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.ui.ExpectedConditions; import org.openqa.selenium.support.ui.WebDriverWait; +import java.io.File; import java.io.IOException; import java.time.Duration; -import java.util.ArrayList; import java.util.List; /** - * Automates product component src/components/forms/QueryInfoForms, with BulkUpdateForm.d.ts + * Automates product component src/components/forms/QueryInfoForms, with BulkUpdateForm.d.ts
+ *
+ * `fieldIdentifier` arguments accept field names or {@link FieldKey}s */ public class EntityBulkUpdateDialog extends ModalDialog { @@ -58,18 +61,26 @@ public EntityBulkUpdateDialog adjustChangeCounter(int change) return this; } - // enable/disable field editable state - - public boolean isFieldEnabled(String fieldKey) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + */ + public boolean isFieldEnabled(CharSequence fieldIdentifier) { - return elementCache().getToggle(fieldKey).isOn(); + return elementCache().getToggle(fieldIdentifier).isOn(); } - public EntityBulkUpdateDialog setEditableState(String fieldKey, boolean enable) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + */ + public EntityBulkUpdateDialog setEditableState(CharSequence fieldIdentifier, boolean enable) { - elementCache().getToggle(fieldKey).set(enable); - if (enable) _changeCounter++; - else _changeCounter--; + ToggleButton toggle = elementCache().getToggle(fieldIdentifier); + if (toggle.isOn() != enable) + { + toggle.set(enable); + if (enable) _changeCounter++; + else _changeCounter--; + } return this; } @@ -82,121 +93,212 @@ private WebDriverWait waiter() public void setValue(FieldDefinition field, Object newValue) { if (field.getType() == FieldDefinition.ColumnType.TextChoice || field.getLookup() != null) - setSelectionField(EscapeUtil.fieldKeyEncodePart(field.getName()), newValue instanceof String ? List.of((String) newValue) : (List) newValue); + setSelectionField(field.getName(), newValue instanceof String ? List.of((String) newValue) : (List) newValue); else if (field.getType() == FieldDefinition.ColumnType.Integer || field.getType() == FieldDefinition.ColumnType.Decimal || field.getType() == FieldDefinition.ColumnType.Double) - setNumericField(EscapeUtil.fieldKeyEncodePart(field.getName()), String.valueOf(newValue)); + setNumericField(field.getName(), String.valueOf(newValue)); else if (field.getType() == FieldDefinition.ColumnType.Date || field.getType() == FieldDefinition.ColumnType.DateAndTime || field.getType() == FieldDefinition.ColumnType.Time) - setDateField(EscapeUtil.fieldKeyEncodePart(field.getName()), (String) newValue); + setDateField(field.getName(), (String) newValue); else if (field.getType() == FieldDefinition.ColumnType.Boolean) - setBooleanField(EscapeUtil.fieldKeyEncodePart(field.getName()), (Boolean) newValue); + setBooleanField(field.getName(), (Boolean) newValue); else if (field.getType() == FieldDefinition.ColumnType.MultiLine) - setTextArea(EscapeUtil.fieldKeyEncodePart(field.getName()), (String) newValue); + setTextArea(field.getName(), (String) newValue); else - setTextField(EscapeUtil.fieldKeyEncodePart(field.getName()), (String) newValue); + setTextField(field.getName(), (String) newValue); } // interact with selection fields - public EntityBulkUpdateDialog setSelectionField(String fieldKey, List selectValues) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @param selectValues value to select + * @return this component + */ + public EntityBulkUpdateDialog setSelectionField(CharSequence fieldIdentifier, List selectValues) { - setEditableState(fieldKey, true); - FilteringReactSelect reactSelect = elementCache().getSelect(fieldKey); - WebDriverWrapper.waitFor(reactSelect::isEnabled, - "the ["+fieldKey+"] reactSelect did not become enabled in time", WAIT_TIMEOUT); + FilteringReactSelect reactSelect = enableSelectionField(fieldIdentifier); selectValues.forEach(reactSelect::filterSelect); return this; } - public List getSelectionOptions(String fieldKey) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @param selectValue value to select + * @return this component + */ + public EntityBulkUpdateDialog setSelectionField(CharSequence fieldIdentifier, String selectValue) { - return enableAndWait(fieldKey, elementCache().getSelect(fieldKey)).getOptions(); + return setSelectionField(fieldIdentifier, List.of(selectValue)); } - public List getSelectionFieldValues(String fieldKey) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return available options for the specified field + */ + public List getSelectionOptions(CharSequence fieldIdentifier) { - return enableAndWait(fieldKey, elementCache().getSelect(fieldKey)).getSelections(); + return enableSelectionField(fieldIdentifier).getOptions(); } - public EntityBulkUpdateDialog setTextArea(String fieldKey, String text) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return selected options for the specified field + */ + public List getSelectionFieldValues(CharSequence fieldIdentifier) { - enableAndWait(fieldKey, elementCache().textArea(fieldKey)).set(text); - return this; + return enableSelectionField(fieldIdentifier).getSelections(); } - public String getTextArea(String fieldKey) + private @NotNull FilteringReactSelect enableSelectionField(CharSequence fieldIdentifier) { - return elementCache().textArea(fieldKey).get(); + setEditableState(fieldIdentifier, true); + FilteringReactSelect reactSelect = elementCache().getSelect(fieldIdentifier); + WebDriverWrapper.waitFor(reactSelect::isEnabled, + "the ["+ fieldIdentifier +"] reactSelect did not become enabled in time", WAIT_TIMEOUT); + return reactSelect; } - // get/set text fields with ID + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @param value value to set + * @return this component + */ + public EntityBulkUpdateDialog setTextArea(CharSequence fieldIdentifier, String value) + { + enableAndWait(fieldIdentifier, elementCache().textArea(fieldIdentifier)).set(value); + return this; + } + + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return current value of the specified field + */ + public String getTextArea(CharSequence fieldIdentifier) + { + return elementCache().textArea(fieldIdentifier).get(); + } - public EntityBulkUpdateDialog setTextField(String fieldKey, String value) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @param value value to set + * @return this component + */ + public EntityBulkUpdateDialog setTextField(CharSequence fieldIdentifier, String value) { - enableAndWait(fieldKey, elementCache().textInput(fieldKey)).set(value); + enableAndWait(fieldIdentifier, elementCache().textInput(fieldIdentifier)).set(value); return this; } - public String getTextField(String fieldKey) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return current value of the specified field + */ + public String getTextField(CharSequence fieldIdentifier) { - return enableAndWait(fieldKey, elementCache().textInput(fieldKey)).get(); + return enableAndWait(fieldIdentifier, elementCache().textInput(fieldIdentifier)).get(); } - public EntityBulkUpdateDialog setNumericField(String fieldKey, String value) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @param value value to set + * @return this component + */ + public EntityBulkUpdateDialog setNumericField(CharSequence fieldIdentifier, String value) { - enableAndWait(fieldKey, elementCache().numericInput(fieldKey)).set(value); + enableAndWait(fieldIdentifier, elementCache().numericInput(fieldIdentifier)).set(value); return this; } - public String getNumericField(String fieldKey) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return current value of the specified field + */ + public String getNumericField(CharSequence fieldIdentifier) { - return elementCache().numericInput(fieldKey).get(); + return elementCache().numericInput(fieldIdentifier).get(); } - public EntityBulkUpdateDialog setDateField(String fieldKey, String dateString) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @param dateString string representation of date to set + * @return this component + */ + public EntityBulkUpdateDialog setDateField(CharSequence fieldIdentifier, String dateString) { - enableAndWait(fieldKey, elementCache().dateInput(fieldKey)).set(dateString); + enableAndWait(fieldIdentifier, elementCache().dateInput(fieldIdentifier)).set(dateString); return this; } - public String getDateField(String fieldKey) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return current value of the specified field + */ + public String getDateField(CharSequence fieldIdentifier) { - return elementCache().dateInput(fieldKey).get(); + return elementCache().dateInput(fieldIdentifier).get(); } - public FileAttachmentContainer getFileField(String fieldKey) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return file attachment component + */ + public FileAttachmentContainer getFileField(CharSequence fieldIdentifier) { - return elementCache().fileUploadField(fieldKey); + fieldIdentifier = EntityBulkInsertDialog.fileUploadFieldKey(fieldIdentifier); + return enableAndWait(fieldIdentifier, elementCache().fileUploadField(fieldIdentifier)); } - public EntityBulkUpdateDialog removeFile(String fieldKey) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @param file file to attach + * @return this component + */ + public EntityBulkUpdateDialog attachFile(CharSequence fieldIdentifier, File file) { - getFileField(fieldKey).removeFile(); - _changeCounter++; + getFileField(fieldIdentifier).attachFile(file); return this; } - public EntityBulkUpdateDialog setBooleanField(String fieldKey, boolean checked) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return this component + */ + public EntityBulkUpdateDialog removeFile(CharSequence fieldIdentifier) { - enableAndWait(fieldKey, getCheckBox(fieldKey)).set(checked); + getFileField(fieldIdentifier).removeFile(); return this; } - private > T enableAndWait(String fieldKey, T formItem) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @param checked value to set + * @return this component + */ + public EntityBulkUpdateDialog setBooleanField(CharSequence fieldIdentifier, boolean checked) { - setEditableState(fieldKey, true); + enableAndWait(fieldIdentifier, getCheckBox(fieldIdentifier)).set(checked); + return this; + } + + private > T enableAndWait(CharSequence fieldIdentifier, T formItem) + { + setEditableState(fieldIdentifier, true); // "Clickable" means visible and enabled waiter().until(ExpectedConditions.elementToBeClickable(formItem.getComponentElement())); return formItem; } - public boolean getBooleanField(String fieldKey) + /** + * @param fieldIdentifier Identifier for the field; name ({@link String}) or fieldKey ({@link FieldKey}) + * @return current value of the specified field + */ + public boolean getBooleanField(CharSequence fieldIdentifier) { - return getCheckBox(fieldKey).get(); + return getCheckBox(fieldIdentifier).get(); } - private Checkbox getCheckBox(String fieldKey) + private Checkbox getCheckBox(CharSequence fieldIdentifier) { - WebElement row = elementCache().formRow(fieldKey); + WebElement row = elementCache().formRow(fieldIdentifier); return new Checkbox(elementCache().checkBoxLoc.findElement(row)); } @@ -215,7 +317,7 @@ public List getFieldNames() List labels = Locator.tagWithClass("label", "control-label").withAttribute("for") .waitForElements(elementCache(), 2_000); - return labels.stream().map(a -> EscapeUtil.fieldKeyDecodePart(a.getDomAttribute("for"))).toList(); + return labels.stream().map(a -> FieldKey.fromFieldKey(a.getDomAttribute("for")).getName()).toList(); } public EntityBulkUpdateDialog waitForFieldsToBe(List expectedFieldNames, int waitMilliseconds) @@ -321,50 +423,50 @@ protected ElementCache elementCache() protected class ElementCache extends ModalDialog.ElementCache { - public WebElement formRow(String fieldKey) + public WebElement formRow(CharSequence fieldIdentifier) { + String fieldKey = FieldKey.fromName(fieldIdentifier).toString(); return Locator.tagWithClass("div", "row") .withChild(Locator.tagWithAttribute("label", "for", fieldKey)) .waitForElement(this, WAIT_TIMEOUT); } - public ToggleButton getToggle(String fieldKey) + public ToggleButton getToggle(CharSequence fieldIdentifier) { - return new ToggleButton.ToggleButtonFinder(getDriver()).waitFor(formRow(fieldKey)); + return new ToggleButton.ToggleButtonFinder(getDriver()).waitFor(formRow(fieldIdentifier)); } - public FilteringReactSelect getSelect(String fieldKey) + public FilteringReactSelect getSelect(CharSequence fieldIdentifier) { - return FilteringReactSelect.finder(getDriver()).withNamedInput(fieldKey).refindWhenNeeded(this); + return new FilteringReactSelect(formRow(fieldIdentifier), getDriver()); } - public Input textInput(String fieldKey) + public Input textInput(CharSequence fieldIdentifier) { - WebElement inputEl = textInputLoc.waitForElement(formRow(fieldKey), WAIT_TIMEOUT); + WebElement inputEl = textInputLoc.waitForElement(formRow(fieldIdentifier), WAIT_TIMEOUT); return new Input(inputEl, getDriver()); } - public Input textArea(String fieldKey) + public Input textArea(CharSequence fieldIdentifier) { - WebElement inputEl = Locator.textarea(fieldKey).waitForElement(formRow(fieldKey), WAIT_TIMEOUT); + WebElement inputEl = Locator.tag("textarea").waitForElement(formRow(fieldIdentifier), WAIT_TIMEOUT); return new Input(inputEl, getDriver()); } - public Input numericInput(String fieldKey) + public Input numericInput(CharSequence fieldIdentifier) { - WebElement inputEl = numberInputLoc.waitForElement(formRow(fieldKey), WAIT_TIMEOUT); + WebElement inputEl = numberInputLoc.waitForElement(formRow(fieldIdentifier), WAIT_TIMEOUT); return new Input(inputEl, getDriver()); } - public ReactDateTimePicker dateInput(String fieldKey) + public ReactDateTimePicker dateInput(CharSequence fieldIdentifier) { - return new ReactDateTimePicker.ReactDateTimeInputFinder(getDriver()) - .withInputId(fieldKey).waitFor(formRow(fieldKey)); + return new ReactDateTimePicker.ReactDateTimeInputFinder(getDriver()).waitFor(formRow(fieldIdentifier)); } - public FileAttachmentContainer fileUploadField(String fieldKey) + public FileAttachmentContainer fileUploadField(CharSequence fieldIdentifier) { - return new FileAttachmentContainer(formRow(fieldKey), getDriver()); + return new FileAttachmentContainer(formRow(fieldIdentifier), getDriver()); } final Locator textInputLoc = Locator.tagWithAttribute("input", "type", "text"); diff --git a/src/org/labkey/test/components/ui/entities/EntityInsertPanel.java b/src/org/labkey/test/components/ui/entities/EntityInsertPanel.java index 4852013037..cb80a53b0b 100644 --- a/src/org/labkey/test/components/ui/entities/EntityInsertPanel.java +++ b/src/org/labkey/test/components/ui/entities/EntityInsertPanel.java @@ -66,16 +66,20 @@ public boolean isAddParentMenuEnabled() return Locator.buttonContainingText("Add Parent").findElement(this).isEnabled(); } + /** + * Get 'Add Parent' menu for passive inspection. Use {@link EntityInsertPanel#addParent(String)} to actually add + * parents to the entity + */ public MultiMenu getAddParentMenu() { - return elementCache().addParent; + return new ReadOnlyMenu(elementCache().addParent, "Parent"); } public EntityInsertPanel addParent(String parentType) { Assert.assertTrue("Add Parent menu not present", isAddParentMenuPresent()); getWrapper().shortWait().until(ExpectedConditions.elementToBeClickable(elementCache().addParent.getComponentElement())); - getAddParentMenu().doMenuAction(parentType); + getEditableGrid().doAndWaitForColumnUpdate(() -> elementCache().addParent.doMenuAction(parentType)); return this; } @@ -89,23 +93,31 @@ public boolean isAddSourceMenuEnabled() return Locator.buttonContainingText("Add Source").findElement(this).isEnabled(); } + /** + * Get 'Add Source' menu for passive inspection. Use {@link EntityInsertPanel#addSource(String)} to actually add + * sources to the entity + */ public MultiMenu getAddSourceMenu() { - return elementCache().addSource; + return new ReadOnlyMenu(elementCache().addSource, "Source"); } public EntityInsertPanel addSource(String sourceType) { Assert.assertTrue("Add Source menu not present", isAddSourceMenuPresent()); getWrapper().shortWait().until(ExpectedConditions.elementToBeClickable(elementCache().addSource.getComponentElement())); - getAddSourceMenu().doMenuAction(sourceType); + getEditableGrid().doAndWaitForColumnUpdate(() -> elementCache().addSource.doMenuAction(sourceType)); return this; } - public EntityInsertPanel removeColumn(String columnName) + /** + * @param columnIdentifier fieldKey, name, or label + * @return this component + */ + public EntityInsertPanel removeColumn(CharSequence columnIdentifier) { showGrid(); - elementCache().grid.removeColumn(columnName); + elementCache().grid.removeColumn(columnIdentifier); return this; } @@ -205,7 +217,7 @@ public List getColumnHeaders() public List> getGridData() { - return getEditableGrid().getGridData(); + return getEditableGrid().getGridDataByLabel(); } public boolean isGridVisible() @@ -317,7 +329,7 @@ public EntityInsertPanel showGrid() WebDriverWrapper.waitFor(this::isGridVisible, "the grid did bot become visible", 2000); } - elementCache().grid.waitForLoaded(); + elementCache().grid.waitForReady(); return this; } public ResponsiveGrid uploadFileExpectingPreview(File file) @@ -407,14 +419,14 @@ protected class ElementCache extends Component.ElementCache WebElement targetTab = navTab.findWhenNeeded(this); MultiMenu addParent = new MultiMenu.MultiMenuFinder(getDriver()).containsText("Add Parent") - .timeout(WAIT_FOR_JAVASCRIPT).refindWhenNeeded(); + .timeout(WAIT_FOR_JAVASCRIPT).refindWhenNeeded(this); MultiMenu addSource = new MultiMenu.MultiMenuFinder(getDriver()).containsText("Add Source") - .timeout(WAIT_FOR_JAVASCRIPT).refindWhenNeeded(); + .timeout(WAIT_FOR_JAVASCRIPT).refindWhenNeeded(this); RadioButton allowMergeRadio = RadioButton.RadioButton(Locator.radioButtonByNameAndValue("insertOption", "true")).findWhenNeeded(this); RadioButton notAllowMergeRadio = RadioButton.RadioButton(Locator.radioButtonByNameAndValue("insertOption", "false")).findWhenNeeded(this); - EditableGrid grid = new EditableGrid.EditableGridFinder(_driver).timeout(WAIT_FOR_JAVASCRIPT).findWhenNeeded(); + EditableGrid grid = new EditableGrid.EditableGridFinder(_driver).timeout(WAIT_FOR_JAVASCRIPT).findWhenNeeded(this); private Optional optionalGrid() { @@ -423,7 +435,7 @@ private Optional optionalGrid() private Optional optionalFileUploadPanel() { - return new FileUploadPanel.FileUploadPanelFinder(getDriver()).findOptional(); + return new FileUploadPanel.FileUploadPanelFinder(getDriver()).findOptional(this); } protected FileUploadPanel fileUploadPanel() @@ -436,7 +448,7 @@ protected FileUploadPanel fileUploadPanel() public boolean hasTabs() { - return Locator.tagWithClassContaining("ul", "list-group").existsIn(elementCache()); + return Locator.tagWithClassContaining("ul", "list-group").existsIn(this); } } @@ -463,3 +475,30 @@ protected Locator locator() } } } + +/** + * Provides read-only access to parent and source menus + * Prevents tests from using them to add parents or sources without proper grid synchronization + */ +class ReadOnlyMenu extends MultiMenu +{ + private final String _entityType; + + ReadOnlyMenu(MultiMenu menu, String entityType) + { + super(menu.getComponentElement(), menu.getWrapper().getDriver()); + _entityType = entityType; + } + + @Override + public void doMenuAction(String toggleText, String menuAction) + { + throw new UnsupportedOperationException("Use add%s()".formatted(_entityType)); + } + + @Override + public void doMenuAction(String menuAction) + { + throw new UnsupportedOperationException("Use add%s()".formatted(_entityType)); + } +} \ No newline at end of file diff --git a/src/org/labkey/test/components/ui/entities/ParentEntityEditPanel.java b/src/org/labkey/test/components/ui/entities/ParentEntityEditPanel.java index 24a81b7030..e71980e918 100644 --- a/src/org/labkey/test/components/ui/entities/ParentEntityEditPanel.java +++ b/src/org/labkey/test/components/ui/entities/ParentEntityEditPanel.java @@ -317,7 +317,7 @@ private BaseReactSelect.BaseReactSelectFinder getParentFin */ public FilteringReactSelect getParent(String typeName) { - return getParentFinder(typeName).find(elementCache()); + return getParentFinder(typeName).waitFor(elementCache()); } /** diff --git a/src/org/labkey/test/components/ui/grids/DetailTable.java b/src/org/labkey/test/components/ui/grids/DetailTable.java index 41ceaa78e4..5bf3c13446 100644 --- a/src/org/labkey/test/components/ui/grids/DetailTable.java +++ b/src/org/labkey/test/components/ui/grids/DetailTable.java @@ -4,6 +4,7 @@ import org.labkey.test.WebDriverWrapper; import org.labkey.test.components.Component; import org.labkey.test.components.WebDriverComponent; +import org.labkey.test.params.FieldKey; import org.openqa.selenium.By; import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.StaleElementReferenceException; @@ -11,6 +12,7 @@ import org.openqa.selenium.WebElement; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -115,7 +117,7 @@ public String getFieldValue(String fieldlabel) } /** - * Gets the value of a cell identified by it's data-fieldKey attribute + * Gets the value of a cell identified by its data-fieldKey attribute * @param fieldKey value of the data-fieldKey attribute on the intended element * @return Text value of the specified element */ @@ -150,7 +152,7 @@ public void clickField(String fieldLabel) **/ public Map getTableDataByLabel() { - Map tableData = new HashMap<>(); + Map tableData = new LinkedHashMap<>(); for(WebElement tableRow : getComponentElement().findElements(By.cssSelector("tr"))) { @@ -162,6 +164,51 @@ public Map getTableDataByLabel() return tableData; } + /** + * Returns a map of the values in the grid. Data is keyed by column FieldKeys. + * + * @return A map with string values. + **/ + public Map getTableDataByFieldKey() + { + Map tableData = new LinkedHashMap<>(); + + for(WebElement tableRow : Locator.tag("tr").findElements(getComponentElement())) + { + WebElement dataCell = Locator.tag("td").withAttribute("data-fieldkey").findElement(tableRow); + + tableData.put(FieldKey.fromFieldKey(dataCell.getDomAttribute("data-fieldkey")), dataCell.getText()); + } + + return tableData; + } + + /** + * Returns a map of the values in the grid. Data is keyed by column names. + * Warning: Names are not guaranteed to be unique. + * + * @return A map with string values. + **/ + public Map getTableDataByName() + { + Map tableDataByFieldKey = getTableDataByFieldKey(); + Map tableDataByName = new LinkedHashMap<>(); + Map collisionChecker = new HashMap<>(); + + for (Map.Entry entry : tableDataByFieldKey.entrySet()) + { + String name = entry.getKey().getName(); + if (collisionChecker.containsKey(name)) + { + throw new IllegalStateException("Ambiguous field name '%s' from FieldKeys '%s' and '%s'." + .formatted(name, collisionChecker.get(name), entry.getKey())); + } + collisionChecker.put(name, entry.getKey()); + tableDataByName.put(name, entry.getValue()); + } + return tableDataByName; + } + protected static abstract class Locators { static final Locator.XPathLocator detailTable = Locator.tagWithClass("table", "detail-component--table__fixed"); diff --git a/src/org/labkey/test/components/ui/grids/DetailTableEdit.java b/src/org/labkey/test/components/ui/grids/DetailTableEdit.java index e8705fb198..0e0c3afbc9 100644 --- a/src/org/labkey/test/components/ui/grids/DetailTableEdit.java +++ b/src/org/labkey/test/components/ui/grids/DetailTableEdit.java @@ -15,8 +15,9 @@ import org.labkey.test.components.react.ReactSelect; import org.labkey.test.components.ui.files.FileUploadField; import org.labkey.test.params.FieldDefinition; +import org.labkey.test.params.FieldKey; import org.labkey.test.util.AuditLogHelper; -import org.labkey.test.util.EscapeUtil; +import org.labkey.test.util.LogMethod; import org.openqa.selenium.By; import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.WebDriver; @@ -28,6 +29,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; @@ -86,89 +88,81 @@ public DetailTableEdit adjustChangeCounter(int change) return this; } - public boolean isFieldPresent(String fieldLabel) + /** + * @param columnIdentifier fieldKey, name, or label + */ + public boolean isFieldPresent(CharSequence columnIdentifier) { - return elementCache().valueCellWithLabel(fieldLabel) != null; + try + { + elementCache().findValueCell(columnIdentifier); + return true; + } + catch (NoSuchElementException e) + { + return false; + } } /** * Check to see if a field is editable. Could be state dependent, that is it returns false if the field is * loading but if checked later could return true. * - * @param fieldLabel The label of the field to check. + * @param columnIdentifier fieldKey, name, or label * @return True if it is false otherwise. **/ - public boolean isFieldEditable(String fieldLabel) + public boolean isFieldEditable(CharSequence columnIdentifier) { // TODO Could put a check here to see if a field is loading then return false, or wait. - WebElement fieldValueElement = elementCache().valueCellWithLabel(fieldLabel); + WebElement fieldValueElement = elementCache().findValueCell(columnIdentifier); return isEditableField(fieldValueElement); } private boolean isEditableField(WebElement element) { // If the div does not have the class value of 'field__un-editable' then it is an editable field. - return Locator.css("div:not(.field__un-editable)").findOptionalElement(element).isPresent(); + return !Locator.byClass("field__un-editable").existsIn(element); } /** * Get the value of a read only field. * - * @param fieldLabel The label of the field to get. + * @param columnIdentifier fieldKey, name, or label * @return The value in the field. **/ - public String getReadOnlyField(String fieldLabel) + public String getReadOnlyField(CharSequence columnIdentifier) { - WebElement fieldValueElement = elementCache().valueCellWithLabel(fieldLabel); - return fieldValueElement.findElement(By.xpath("./div/*")).getText(); + WebElement fieldValueElement = elementCache().findValueCell(columnIdentifier); + return Locator.xpath("./div/*").findElement(fieldValueElement).getText(); } /** * Get the value of a text field. * - * @param fieldLabel The label of the field to get. + * @param columnIdentifier fieldKey, name, or label * @return The value in the field. **/ - public String getTextField(String fieldLabel) + public String getTextField(CharSequence columnIdentifier) { - WebElement fieldValueElement = elementCache().valueCellWithLabel(fieldLabel); - WebElement textElement = fieldValueElement.findElement(By.xpath("./div/div/*")); - if (textElement.getTagName().equalsIgnoreCase("textarea")) - return textElement.getText(); - else - return textElement.getAttribute("value"); + return elementCache().findInput(columnIdentifier).get(); } /** * Set a text field. * - * @param fieldLabel The label of the field to set. + * @param columnIdentifier fieldKey, name, or label * @param value The value to set the field to. * @return A reference to this editable detail table. **/ - public DetailTableEdit setTextField(String fieldLabel, String value) + public DetailTableEdit setTextField(CharSequence columnIdentifier, String value) { - if (isFieldEditable(fieldLabel)) + if (isFieldEditable(columnIdentifier)) { - WebElement fieldValueElement = elementCache().valueCellWithLabel(fieldLabel); - - WebElement editableElement = fieldValueElement.findElement(By.xpath("./div/div/*")); - String elementType = editableElement.getTagName().toLowerCase().trim(); - - switch (elementType) - { - case "textarea": - case "input": - editableElement.clear(); - WebDriverWrapper.waitFor(()->editableElement.getText().isEmpty(), 500); - editableElement.sendKeys(value); - break; - default: - throw new NoSuchElementException("This doesn't look like an 'input' or 'textarea' element, are you sure you are calling the correct method?"); - } + Input input = elementCache().findInput(columnIdentifier); + input.setValue(value); } else { - throw new IllegalArgumentException("Field with label '" + fieldLabel + "' is read-only. This field can not be set."); + throw new IllegalArgumentException("Field '" + columnIdentifier + "' is read-only. This field can not be set."); } _changeCounter++; @@ -200,16 +194,16 @@ public DetailTableEdit setTextareaByFieldName(String fieldName, String value) /** * Get the value of a boolean field. * - * @param fieldLabel The label of the field to get. + * @param columnIdentifier fieldKey, name, or label * @return The value of the field. **/ - public boolean getBooleanField(String fieldLabel) + public boolean getBooleanField(CharSequence columnIdentifier) { // The text used in the field label and the value of the name attribute in the checkbox don't always have the same case. - WebElement editableElement = Locator.tag("input").findElement(elementCache().valueCellWithLabel(fieldLabel)); + WebElement editableElement = Locator.tag("input").findElement(elementCache().findValueCell(columnIdentifier)); String elementType = editableElement.getDomAttribute("type").toLowerCase().trim(); - Assert.assertEquals(String.format("Field '%s' is not a checkbox. Cannot be get true/false value.", fieldLabel), "checkbox", elementType); + Assert.assertEquals(String.format("Field '%s' is not a checkbox. Cannot be get true/false value.", columnIdentifier), "checkbox", elementType); return new Checkbox(editableElement).isChecked(); } @@ -217,21 +211,21 @@ public boolean getBooleanField(String fieldLabel) /** * Set a boolean field (a checkbox). * - * @param fieldLabel The label of the field to set. + * @param columnIdentifier fieldKey, name, or label * @param value True will check it, false will uncheck it. * @return A reference to this editable detail table. **/ - public DetailTableEdit setBooleanField(String fieldLabel, boolean value) + public DetailTableEdit setBooleanField(CharSequence columnIdentifier, boolean value) { - WebElement fieldValueElement = elementCache().valueCellWithLabel(fieldLabel); - Assert.assertTrue(String.format("Field '%s' is not editable and cannot be set.", fieldLabel), isEditableField(fieldValueElement)); + WebElement fieldValueElement = elementCache().findValueCell(columnIdentifier); + Assert.assertTrue(String.format("Field '%s' is not editable and cannot be set.", columnIdentifier), isEditableField(fieldValueElement)); getWrapper().scrollIntoView(fieldValueElement); WebElement editableElement = fieldValueElement.findElement(By.xpath("./div/div/input")); String elementType = editableElement.getDomAttribute("type").toLowerCase().trim(); - Assert.assertEquals(String.format("Field '%s' is not a checkbox. Cannot be set to true/false.", fieldLabel), "checkbox", elementType); + Assert.assertEquals(String.format("Field '%s' is not a checkbox. Cannot be set to true/false.", columnIdentifier), "checkbox", elementType); Checkbox checkbox = new Checkbox(editableElement); @@ -244,98 +238,97 @@ public DetailTableEdit setBooleanField(String fieldLabel, boolean value) /** * Get the value of an int field. You could also call getTextField * - * @param fieldLabel The label of the field to get. + * @param columnIdentifier fieldKey, name, or label * @return The value of the field as an int. **/ - public int getIntField(String fieldLabel) + public int getIntField(CharSequence columnIdentifier) { - return Integer.getInteger(getTextField(fieldLabel)); + return Integer.getInteger(getTextField(columnIdentifier)); } /** * Set an int field. * - * @param fieldLabel The label of the field to set. + * @param columnIdentifier fieldKey, name, or label * @param value The int value to set the field to. * @return A reference to this editable detail table. **/ - public DetailTableEdit setIntField(String fieldLabel, int value) + public DetailTableEdit setIntField(CharSequence columnIdentifier, int value) { - return setTextField(fieldLabel, Integer.toString(value)); + return setTextField(columnIdentifier, Integer.toString(value)); } - public FileUploadField getFileField(String fieldLabel) + public FileUploadField getFileField(CharSequence columnIdentifier) { - return elementCache().fileField(fieldLabel); + return elementCache().fileField(columnIdentifier); } - public DetailTableEdit setFileField(String fieldLabel, File file) + public DetailTableEdit setFileField(CharSequence columnIdentifier, File file) { - getFileField(fieldLabel) + getFileField(columnIdentifier) .setFile(file); _changeCounter++; return this; } - public DetailTableEdit removeFileField(String fieldLabel) + public DetailTableEdit removeFileField(CharSequence columnIdentifier) { - getFileField(fieldLabel).removeFile(); + getFileField(columnIdentifier).removeFile(); _changeCounter++; return this; } - public boolean isFileFieldBlank(String fieldLabel) + public boolean isFileFieldBlank(CharSequence columnIdentifier) { - return !getFileField(fieldLabel) + return !getFileField(columnIdentifier) .hasAttachedFile(); } - public FilteringReactSelect getSelectField(String fieldLabel) + public FilteringReactSelect getSelectField(CharSequence columnIdentifier) { - return elementCache().findSelect(fieldLabel); + return elementCache().findSelect(columnIdentifier); } /** * Get the value of a select field. * - * @param fieldLabel The label of the field to get. + * @param columnIdentifier fieldKey, name, or label * @return The selected value. **/ - public String getSelectedValue(String fieldLabel) + public String getSelectedValue(CharSequence columnIdentifier) { - return getSelectField(fieldLabel).getValue(); + return getSelectField(columnIdentifier).getValue(); } /** * This allows you to query a given select in the edit panel to see what options it offers. * - * @param fieldLabel The label of the field to get. + * @param columnIdentifier fieldKey, name, or label * @return List of strings for the values in the list. **/ - public List getSelectOptions(String fieldLabel) + public List getSelectOptions(CharSequence columnIdentifier) { - return getSelectField(fieldLabel).getOptions(); + return getSelectField(columnIdentifier).getOptions(); } /** * Select a single value from a select list. * - * @param fieldLabel The label of the field to set. + * @param columnIdentifier fieldKey, name, or label * @param selectValue The value to select from the list. * @return A reference to this editable detail table. **/ - public DetailTableEdit setSelectValue(String fieldLabel, String selectValue) + public DetailTableEdit setSelectValue(CharSequence columnIdentifier, String selectValue) { List selection = Arrays.asList(selectValue); - return setSelectValue(fieldLabel, selection); + return setSelectValue(columnIdentifier, selection); } - public DetailTableEdit createSelectValue(String fieldLabel, String value) + public DetailTableEdit createSelectValue(CharSequence columnIdentifier, String value) { - WebElement container = Locator.tag("td").withAttribute("data-caption", fieldLabel).findElement(this); - var select = ReactSelect.finder(getDriver()).waitFor(container); + var select = ReactSelect.finder(getDriver()).waitFor(elementCache().findValueCell(columnIdentifier)); select.createValue(value); return this; } @@ -343,13 +336,13 @@ public DetailTableEdit createSelectValue(String fieldLabel, String value) /** * Select multiple values from a select list. * - * @param fieldLabel The label of the field to set. + * @param columnIdentifier fieldKey, name, or label * @param selectValues The value to select from the list. * @return A reference to this editable detail table. **/ - public DetailTableEdit setSelectValue(String fieldLabel, List selectValues) + public DetailTableEdit setSelectValue(CharSequence columnIdentifier, List selectValues) { - FilteringReactSelect reactSelect = getSelectField(fieldLabel); + FilteringReactSelect reactSelect = getSelectField(columnIdentifier); selectValues.forEach(reactSelect::typeAheadSelect); _changeCounter++; return this; @@ -358,31 +351,31 @@ public DetailTableEdit setSelectValue(String fieldLabel, List selectValu /** * Clear a given select field. * - * @param fieldLabel The label of the field to clear. + * @param columnIdentifier fieldKey, name, or label * @return A reference to this editable detail table. **/ - public DetailTableEdit clearSelectValue(String fieldLabel) + public DetailTableEdit clearSelectValue(CharSequence columnIdentifier) { - return clearSelectValue(fieldLabel, true, true); + return clearSelectValue(columnIdentifier, true, true); } /** * Clear a given select field. * - * @param fieldLabel The label of the field to clear. + * @param columnIdentifier fieldKey, name, or label * @param waitForSelection If true, wait for the select to have a selection before clearing it * @param assertSelection If true, assert if no selection appears (note: does nothing if waitForSelection is not true) * @return A reference to this editable detail table. */ - public DetailTableEdit clearSelectValue(String fieldLabel, boolean waitForSelection, boolean assertSelection) + public DetailTableEdit clearSelectValue(CharSequence columnIdentifier, boolean waitForSelection, boolean assertSelection) { - var select = getSelectField(fieldLabel); + var select = getSelectField(columnIdentifier); if (waitForSelection) { if (assertSelection) { WebDriverWrapper.waitFor(select::hasSelection, - String.format("The %s select did not have any selection in time", fieldLabel), _readyTimeout); + String.format("The %s select did not have any selection in time", columnIdentifier), _readyTimeout); } else WebDriverWrapper.waitFor(select::hasSelection, 1_000); @@ -394,7 +387,7 @@ public DetailTableEdit clearSelectValue(String fieldLabel, boolean waitForSelect /** * Set a DateTime, Date or Time field. - * @param fieldName The name of the field to set. + * @param columnIdentifier fieldKey, name, or label * @param dateTime Will be used to determine what kind of field is being set and how to set it. If the parameter * is a LocalDateTime object then it is assumed that field is a DateTime field. If the parameter is * a LocalDate object then it is assumed to be a date-only field. And I think you can guess what @@ -402,9 +395,9 @@ public DetailTableEdit clearSelectValue(String fieldLabel, boolean waitForSelect * is typed into the field (no picker is used). * @return A reference to this DetailTableEdit object. */ - public DetailTableEdit setDateTimeField(String fieldName, Object dateTime) + public DetailTableEdit setDateTimeField(CharSequence columnIdentifier, Object dateTime) { - ReactDateTimePicker dateTimePicker = getDateTimePicker(fieldName); + ReactDateTimePicker dateTimePicker = getDateTimePicker(columnIdentifier); if (dateTime instanceof LocalDateTime localDateTime) { dateTimePicker.select(localDateTime); @@ -431,22 +424,22 @@ else if (dateTime instanceof String setValue) return this; } - public String getDateTimeField(String fieldName) + public String getDateTimeField(CharSequence columnIdentifier) { - ReactDateTimePicker dateTimePicker = getDateTimePicker(fieldName); + ReactDateTimePicker dateTimePicker = getDateTimePicker(columnIdentifier); return dateTimePicker.get(); } - public void clearDateTimeField(String fieldName) + public void clearDateTimeField(CharSequence columnIdentifier) { - ReactDateTimePicker dateTimePicker = getDateTimePicker(fieldName); + ReactDateTimePicker dateTimePicker = getDateTimePicker(columnIdentifier); dateTimePicker.clear(); _changeCounter++; } - private ReactDateTimePicker getDateTimePicker(String fieldName) + private ReactDateTimePicker getDateTimePicker(CharSequence columnIdentifier) { - return new ReactDateTimePicker.ReactDateTimeInputFinder(getDriver()).find(elementCache().valueCellWithName(fieldName)); + return new ReactDateTimePicker.ReactDateTimeInputFinder(getDriver()).find(elementCache().findValueCell(columnIdentifier)); } // For use when the field is of an unknown type, as can occur in fuzz tests @@ -456,13 +449,13 @@ public void setDetails(FieldDefinition field, Object newValue) return; if (field.getType() == FieldDefinition.ColumnType.TextChoice) - setSelectValue(field.getLabel(), (List) newValue); + setSelectValue(field.getName(), (List) newValue); else if (field.getType() == FieldDefinition.ColumnType.Date || field.getType() == FieldDefinition.ColumnType.DateAndTime || field.getType() == FieldDefinition.ColumnType.Time) setDateTimeField(field.getName(), newValue); else if (field.getType() == FieldDefinition.ColumnType.Boolean) - setBooleanField(field.getLabel(), (Boolean) newValue); + setBooleanField(field.getName(), (Boolean) newValue); else - setTextField(field.getLabel(), String.valueOf(newValue)); + setTextField(field.getName(), String.valueOf(newValue)); } /** @@ -585,24 +578,56 @@ public ElementCache() .invisibilityOfAllElements(Locator.byClass("select-input__loading-indicator").findElements(this))); } - public WebElement header = Locator.tagWithClass("div", "panel-heading") + public final WebElement header = Locator.tagWithClass("div", "panel-heading") .findWhenNeeded(this); - public WebElement editPanel = Locator.tagWithClass("div", "detail__editing") + public final WebElement editPanel = Locator.tagWithClass("div", "detail__editing") .findWhenNeeded(this); - public WebElement valueCellWithLabel(String label) + public WebElement findValueCell(CharSequence columnIdentifier) { - return Locator.tagWithAttribute("td", "data-caption", label).findElementOrNull(editPanel); + return getFieldManager().findFieldReference(columnIdentifier).getElement(); } - public WebElement valueCellWithName(String fieldName) + private FieldReferenceManager _fieldReferenceManager; + + @LogMethod + private FieldReferenceManager getFieldManager() { - return Locator.tagWithAttribute("td", "data-fieldkey", EscapeUtil.fieldKeyEncodePart(fieldName)).findElement(editPanel); + if (_fieldReferenceManager == null) + { + List columnHeaders = new ArrayList<>(); + + List valueCells = Locator.tagWithAttribute("td", "data-fieldkey").findElements(this); + // Use JavaScript to get fieldKeys and captions in one operation, rather than making 2N calls to 'WebElement.getDomAttribute' + List> captionsAndKeys = getWrapper().executeScript( + """ + var cells = arguments[0]; + var captions = []; + var fieldkeys = []; + for (var i = 0; i < cells.length; i++) + { + captions.push(cells[i].dataset.caption); + fieldkeys.push(cells[i].dataset.fieldkey); + } + return [captions, fieldkeys]; + """, List.class, + valueCells); + List captions = captionsAndKeys.get(0); + List fieldkeys = captionsAndKeys.get(1); + for (int i = 0; i < valueCells.size(); i++) + { + columnHeaders.add(new DetailTableEditFieldReference(valueCells.get(i), i, fieldkeys.get(i), captions.get(i))); + } + + _fieldReferenceManager = new FieldReferenceManager(columnHeaders); + } + + return _fieldReferenceManager; } - public FileUploadField fileField(String label) + public FileUploadField fileField(CharSequence columnIdentifier) { - return new FileUploadField(valueCellWithLabel(label), getDriver()); + return new FileUploadField(findValueCell(columnIdentifier), getDriver()); } public Locator validationMsg = Locator.tagWithClass("span", "validation-message"); @@ -614,10 +639,14 @@ public FileUploadField fileField(String label) public WebElement commentInput = Locator.tagWithId("textarea", "actionComments").refindWhenNeeded(getDriver()); - public FilteringReactSelect findSelect(String fieldLabel) + public FilteringReactSelect findSelect(CharSequence columnIdentifier) + { + return FilteringReactSelect.finder(_driver).timeout(_readyTimeout).waitFor(findValueCell(columnIdentifier)); + } + + public Input findInput(CharSequence columnIdentifier) { - WebElement container = Locator.tag("td").withAttribute("data-caption", fieldLabel).findElement(this); - return FilteringReactSelect.finder(_driver).timeout(_readyTimeout).waitFor(container); + return Input.Input(Locator.xpath("./div/div/*[self::input or self::textarea]"), getDriver()).find(findValueCell(columnIdentifier)); } } @@ -652,4 +681,29 @@ protected Locator locator() return _locator; } } + + private static class DetailTableEditFieldReference extends FieldReferenceManager.FieldReference + { + private final FieldKey _fieldKey; + private final String _label; + + public DetailTableEditFieldReference(WebElement element, int domIndex, String fieldKey, String label) + { + super(element, domIndex); + _fieldKey = FieldKey.fromFieldKey(fieldKey); + _label = label; + } + + @Override + public FieldKey getFieldKey() + { + return _fieldKey; + } + + @Override + public String getLabel() + { + return _label; + } + } } diff --git a/src/org/labkey/test/components/ui/grids/EditableGrid.java b/src/org/labkey/test/components/ui/grids/EditableGrid.java index ca33d6a355..0a0dbeef31 100644 --- a/src/org/labkey/test/components/ui/grids/EditableGrid.java +++ b/src/org/labkey/test/components/ui/grids/EditableGrid.java @@ -1,6 +1,8 @@ package org.labkey.test.components.ui.grids; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.mutable.Mutable; +import org.apache.commons.lang3.mutable.MutableObject; import org.assertj.core.api.Assertions; import org.jetbrains.annotations.Nullable; import org.labkey.test.Locator; @@ -13,7 +15,9 @@ import org.labkey.test.components.react.ReactSelect; import org.labkey.test.components.ui.entities.EntityBulkInsertDialog; import org.labkey.test.components.ui.entities.EntityBulkUpdateDialog; +import org.labkey.test.components.ui.grids.FieldReferenceManager.FieldReference; import org.labkey.test.params.FieldDefinition; +import org.labkey.test.params.FieldKey; import org.labkey.test.util.selenium.ScrollUtils; import org.labkey.test.util.selenium.WebElementUtils; import org.openqa.selenium.By; @@ -36,12 +40,13 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Date; -import java.util.HashMap; -import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TimeZone; +import java.util.function.Function; import java.util.stream.Collectors; import static org.awaitility.Awaitility.await; @@ -54,7 +59,8 @@ public class EditableGrid extends WebDriverComponent { public static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); - public static final String SELECT_COLUMN_HEADER = ""; + public static final FieldKey SELECT_COLUMN_ID = FieldKey.fromParts("__select__"); public static final String ROW_NUMBER_COLUMN_HEADER = ""; private final WebElement _gridElement; @@ -83,7 +89,8 @@ public WebElement getComponentElement() return _gridElement; } - public void waitForLoaded() + @Override + public void waitForReady() { Locators.loadingGrid.waitForElementToDisappear(this, 30000); Locators.spinner.waitForElementToDisappear(this, 30000); @@ -91,7 +98,7 @@ public void waitForLoaded() public void clickDelete() { - doAndWaitForUpdate(() -> elementCache().deleteRowsBtn.click()); + doAndWaitForRowCountUpdate(() -> elementCache().deleteRowsBtn.click()); } public EntityBulkInsertDialog clickBulkAdd() @@ -120,22 +127,24 @@ public List getColumnLabels() return elementCache().getColumnLabels(); } - protected Integer getColumnIndex(String fieldLabel) + protected Integer getColumnIndex(CharSequence columnIdentifier) { - List fieldLabels = getColumnLabels(); - for (int i=0; i< fieldLabels.size(); i++ ) - { - if (fieldLabels.get(i).equalsIgnoreCase(fieldLabel)) - return i; - } - throw new NotFoundException("Column not found in grid: " + fieldLabel + ". Found: " + fieldLabels); + return elementCache().getColumnIndex(columnIdentifier); } - public EditableGrid removeColumn(String fieldLabel) + /** + * Remove the specified column from the grid + * @param columnIdentifier fieldKey, name, or label + * @return this component + */ + public EditableGrid removeColumn(CharSequence columnIdentifier) { - WebElement headerCell = elementCache().getGridCellHeader(fieldLabel); - Locator.byClass("fa-chevron-circle-down").findElement(headerCell).click(); - Locator.tagWithText("a", "Remove Column").findElement(headerCell).click(); + doAndWaitForColumnUpdate(() -> + { + WebElement headerCell = elementCache().getColumnHeaderCell(columnIdentifier); + Locator.byClass("fa-chevron-circle-down").findElement(headerCell).click(); + Locator.tagWithText("a", "Remove Column").findElement(headerCell).click(); + }); return this; } @@ -219,47 +228,75 @@ public EditableGrid shiftSelectRange(int start, int end) private List getRows() { - waitForLoaded(); return Locators.rows.findElements(elementCache().table); } - public List> getGridData(String... fieldLabels) + /** + * @param columnIdentifiers fieldKeys, names, or labels of columns + * @return grid data for the specified columns, keyed by column label + */ + public List> getGridDataByLabel(CharSequence... columnIdentifiers) + { + return getGridData(FieldReferenceManager.FieldReference::getLabel, columnIdentifiers); + } + + /** + * @param columnIdentifiers fieldKeys, names, or labels of columns + * @return grid data for the specified columns, keyed by column fieldKey + */ + public List> getGridDataByFieldKey(CharSequence... columnIdentifiers) { - List> gridData = new ArrayList<>(); + return getGridData(FieldReferenceManager.FieldReference::getFieldKey, columnIdentifiers); + } - List allFieldLabels = getColumnLabels(); - Set includedColIndices = new HashSet<>(); - if (fieldLabels.length > 0) + /** + * @param columnIdentifiers fieldKeys, names, or labels of columns + * @return grid data for the specified columns, keyed by column name + */ + public List> getGridDataByName(CharSequence... columnIdentifiers) + { + return getGridData(FieldReference::getName, columnIdentifiers); + } + + private List> getGridData(Function keyGenerator, CharSequence... columnIdentifiers) + { + List> gridData = new ArrayList<>(); + + Set includedColHeaders = new LinkedHashSet<>(); + if (columnIdentifiers.length == 0) { - Assertions.assertThat(allFieldLabels).as("Editable grid columns").contains(fieldLabels); - for (String col : fieldLabels) + includedColHeaders.addAll(elementCache().findHeaders()); + } + else + { + for (CharSequence columnIdentifier : columnIdentifiers) { - int colIndex = allFieldLabels.indexOf(col); - includedColIndices.add(colIndex); + includedColHeaders.add(elementCache().findColumnHeader(columnIdentifier)); } } for (WebElement row : getRows()) { List cells = row.findElements(By.tagName("td")); - Map rowMap = new HashMap<>(); + Map rowMap = new LinkedHashMap<>(includedColHeaders.size()); - for (int i = 0; i < cells.size(); i++) + for (FieldReference fieldReference : includedColHeaders) { - if (includedColIndices.isEmpty() || includedColIndices.contains(i)) - { - WebElement cell = cells.get(i); - String fieldLabel = allFieldLabels.get(i); + WebElement cell = cells.get(fieldReference.getDomIndex()); - if (fieldLabel.equals(SELECT_COLUMN_HEADER)) - { - rowMap.put(fieldLabel, String.valueOf(cell.findElement(By.tagName("input")).isSelected())); - } - else - { - rowMap.put(fieldLabel, cell.getText()); - } + T key = keyGenerator.apply(fieldReference); + String value; + + if (fieldReference.getDomIndex() == 0 && hasSelectColumn()) + { + value = String.valueOf(Locator.tag("input").findElement(cell).isSelected()); + } + else + { + value = cell.getText(); } + + rowMap.put(key, value); } gridData.add(rowMap); @@ -268,9 +305,18 @@ public List> getGridData(String... fieldLabels) return gridData; } - public List getColumnDataByLabel(String fieldLabel) + @Deprecated + public List getColumnDataByLabel(CharSequence columnIdentifier) { - return getGridData(fieldLabel).stream().map(a-> a.get(fieldLabel)).collect(Collectors.toList()); + return getColumnData(columnIdentifier); + } + + /** + * @param columnIdentifier fieldKey, name, or label of column + */ + public List getColumnData(CharSequence columnIdentifier) + { + return getGridData(ch -> 1, columnIdentifier).stream().map(a-> a.get(1)).collect(Collectors.toList()); } private WebElement getRow(int index) @@ -282,15 +328,15 @@ private WebElement getRow(int index) * Find the first row index containing the text value in the given column. * If not found -1 is returned. * - * @param fieldLabel Column label to look at. + * @param columnIdentifier fieldKey, name, or label of column * @param text Text to look for (must match exactly). * @return The first row index where found, -1 if not found. */ - public Integer getRowIndex(String fieldLabel, String text) + public Integer getRowIndex(CharSequence columnIdentifier, String text) { int index = -1; - List columnData = getColumnDataByLabel(fieldLabel); + List columnData = getColumnData(columnIdentifier); for (int i = 0; i < columnData.size(); i++) { if (columnData.get(i).equals(text)) @@ -307,18 +353,18 @@ public Integer getRowIndex(String fieldLabel, String text) * Get the td element for a cell. * * @param row The 0 based row index. - * @param fieldLabel The label of the column to get the cell. + * @param columnIdentifier fieldKey, name, or label of column * @return A {@link WebElement} that is the td for the cell. */ - public WebElement getCell(int row, String fieldLabel) + public WebElement getCell(int row, CharSequence columnIdentifier) { - int columNumber = getColumnIndex(fieldLabel) + 1; + int columNumber = getColumnIndex(columnIdentifier) + 1; return Locator.css("td:nth-of-type(" + columNumber + ")").findElement(getRow(row)); } - public boolean isCellReadOnly(int row, String fieldLabel) + public boolean isCellReadOnly(int row, CharSequence columnIdentifier) { - WebElement div = Locator.tag("div").findElement(getCell(row, fieldLabel)); + WebElement div = Locator.tag("div").findElement(getCell(row, columnIdentifier)); String cellClass = div.getDomAttribute("class"); return cellClass != null && cellClass.contains("cell-read-only"); } @@ -330,44 +376,44 @@ public int getRowCount() /** *

- * For a given column, 'fieldLabelToSet', set the lookup cell in the first row where the value in column 'fieldLabelToSearch' + * For a given column, 'columnToSet', set the lookup cell in the first row where the value in column 'columnToSearch' * equals 'valueToSearch'. The value chosen will be at the specified index in the lookup options. Supply a 'value' in order to * filter the set of options shown. *

* - * @param fieldLabelToSearch The label of the column to check if a row should be updated or not. - * @param valueToSearch The value to check for in 'fieldLabelToSearch' to see if the row should be updated. - * @param fieldLabelToSet The column to update in a row. + * @param columnToSearch fieldKey, name, or label of column to check if a row should be updated or not. + * @param valueToSearch The value to check for in 'columnToSearch' to see if the row should be updated. + * @param columnToSet The column to update in a row. * @param value Optional value to supply for filtering lookup options before selection * @param index The 0-based index of the option to choose from the possibly filtered list of options. */ - public void setCellValueForLookup(String fieldLabelToSearch, String valueToSearch, String fieldLabelToSet, @Nullable String value, int index) + public void setCellValueForLookup(CharSequence columnToSearch, String valueToSearch, CharSequence columnToSet, @Nullable String value, int index) { - setCellValueForLookup(getRowIndex(fieldLabelToSearch, valueToSearch), fieldLabelToSet, value, index); + setCellValueForLookup(getRowIndex(columnToSearch, valueToSearch), columnToSet, value, index); } /** *

- * For a given column, 'fieldLabelToSet', set the cell in the row if value in column 'fieldLabelToSearch' + * For a given column, 'columnToSet', set the cell in the row if value in column 'columnToSearch' * equals 'valueToSearch'. *

*

* Rather than set one cell in a specific row, this function will loop through all the rows in the grid and - * will update the value in column 'fieldLabelToSet' only if the value in the column 'fieldLabelToSearch' equal + * will update the value in column 'columnToSet' only if the value in the column 'columnToSearch' equal * 'valueToSearch' in that row. *

*

* The check for equality for 'valueToSearch' is case sensitive. *

* - * @param fieldLabelToSearch The label of the column to check if a row should be updated or not. - * @param valueToSearch The value to check for in 'fieldLabelToSearch' to see if the row should be updated. - * @param fieldLabelToSet The column to update in a row. - * @param valueToSet The new value to put into column 'fieldLabelToSet'. + * @param columnToSearch fieldKey, name, or label of column to check if a row should be updated or not. + * @param valueToSearch The value to check for in 'columnToSearch' to see if the row should be updated. + * @param columnToSet The column to update in a row. + * @param valueToSet The new value to put into column 'columnToSet'. */ - public void setCellValue(String fieldLabelToSearch, String valueToSearch, String fieldLabelToSet, Object valueToSet) + public void setCellValue(CharSequence columnToSearch, String valueToSearch, CharSequence columnToSet, Object valueToSet) { - setCellValue(getRowIndex(fieldLabelToSearch, valueToSearch), fieldLabelToSet, valueToSet); + setCellValue(getRowIndex(columnToSearch, valueToSearch), columnToSet, valueToSet); } /** @@ -380,13 +426,13 @@ public void setCellValue(String fieldLabelToSearch, String valueToSearch, String *

* * @param row Index of the row (0 based). - * @param fieldLabel Label of the column to update. + * @param columnIdentifier fieldKey, name, or label of column * @param value If the cell is a lookup, value should be List.of(value(s)). To use the date picker pass a 'Date', 'LocalDate', or 'LocalDateTime' * @return cell WebElement */ - public WebElement setCellValue(int row, String fieldLabel, Object value) + public WebElement setCellValue(int row, CharSequence columnIdentifier, Object value) { - return setCellValue(row, fieldLabel, value, true, false); + return setCellValue(row, columnIdentifier, value, true, false); } /** @@ -395,14 +441,14 @@ public WebElement setCellValue(int row, String fieldLabel, Object value) *

* * @param row Index of the row (0 based). - * @param fieldLabel Label of the column to update. + * @param columnIdentifier fieldKey, name, or label of column * @param value Optional value to type in to filter the options shown * @param index The index of the option to select for the lookup * @return cell WebElement */ - public WebElement setCellValueForLookup(int row, String fieldLabel, @Nullable String value, int index) + public WebElement setCellValueForLookup(int row, CharSequence columnIdentifier, @Nullable String value, int index) { - WebElement gridCell = selectCell(row, fieldLabel); + WebElement gridCell = selectCell(row, columnIdentifier); ReactSelect lookupSelect = elementCache().lookupSelect(gridCell); @@ -412,7 +458,7 @@ public WebElement setCellValueForLookup(int row, String fieldLabel, @Nullable St List elements = lookupSelect.getOptionElements(); if (elements.size() < index) - throw new NotFoundException("Could not select option at index " + index + " in lookup for " + fieldLabel + ". Only " + elements.size() + " options found."); + throw new NotFoundException("Could not select option at index " + index + " in lookup for " + columnIdentifier + ". Only " + elements.size() + " options found."); elements.get(index).click(); return gridCell; } @@ -427,13 +473,13 @@ public WebElement setCellValueForLookup(int row, String fieldLabel, @Nullable St *

* * @param row Index of the row (0 based). - * @param fieldLabel Label of the column to update. + * @param columnIdentifier fieldKey, name, or label of column * @param value If the cell is a lookup, value should be List.of(value(s)). To use the date picker pass a 'Date', 'LocalDate', or 'LocalDateTime' * @param checkContains Check to see if the value passed in is contained in the value shown in the grid after the edit. * Will be true most of the time but can be false if the field has formatting that may alter the value passed in like date values. * @return cell WebElement */ - public WebElement setCellValue(int row, String fieldLabel, Object value, boolean checkContains, boolean centerSelectedCell) + public WebElement setCellValue(int row, CharSequence columnIdentifier, Object value, boolean checkContains, boolean centerSelectedCell) { // Normalize date values if (value instanceof Date date) @@ -442,9 +488,9 @@ public WebElement setCellValue(int row, String fieldLabel, Object value, boolean } if (centerSelectedCell) - ScrollUtils.scrollIntoView(getCell(row, fieldLabel), center, center); + ScrollUtils.scrollIntoView(getCell(row, columnIdentifier), center, center); - WebElement gridCell = selectCell(row, fieldLabel); + WebElement gridCell = selectCell(row, columnIdentifier); if (value instanceof List) { @@ -535,10 +581,9 @@ public void setEntityData(List >data, List { Map rowData = data.get(i); for (FieldDefinition field : fields) { - String key = field.getLabel() != null ? field.getLabel() : field.getName(); - Object value = rowData.get(key); + Object value = rowData.get(field.getEffectiveLabel()); if (value != null) - setCellValue(i, key, value); + setCellValue(i, field.getName(), value); } } } @@ -548,8 +593,8 @@ public EditableGrid setRecordValues(List> rowValues) for (int i = 0; i < rowValues.size(); i++) { Map columnValues = rowValues.get(i); - for(String fieldLabel : columnValues.keySet()) - setCellValue(i, fieldLabel, columnValues.get(fieldLabel), true, true); + for(String fieldIdentifier : columnValues.keySet()) + setCellValue(i, fieldIdentifier, columnValues.get(fieldIdentifier), true, true); } return this; } @@ -559,16 +604,16 @@ public EditableGrid setRecordValues(List> rowValues) * Use '\n' for a new line. * * @param row Row to update. - * @param fieldLabel Column label of the multi-line field. + * @param columnIdentifier fieldKey, name, or label of column * @param value The value to set. */ - public void setMultiLineCellValue(int row, String fieldLabel, String value) + public void setMultiLineCellValue(int row, CharSequence columnIdentifier, String value) { - WebElement gridCell = getCell(row, fieldLabel); + WebElement gridCell = getCell(row, columnIdentifier); String beforeText = gridCell.getText(); - WebElement textArea = activateCellUsingDoubleClick(row, fieldLabel); + WebElement textArea = activateCellUsingDoubleClick(row, columnIdentifier); textArea.sendKeys(value, Keys.RETURN); // Add the RETURN to close the inputCell. @@ -585,12 +630,12 @@ public void setMultiLineCellValue(int row, String fieldLabel, String value) * Double-clicking a cell that is "text" value field will activate it and present a textArea for editing the value. * This will return the textArea WebElement that can be used to set the field. * @param row Row to be edited. - * @param fieldLabel Column label of the field. + * @param columnIdentifier fieldKey, name, or label of column * @return The TextArea component that can be used to edit the field. */ - public WebElement activateCellUsingDoubleClick(int row, String fieldLabel) + public WebElement activateCellUsingDoubleClick(int row, CharSequence columnIdentifier) { - WebElement gridCell = getCell(row, fieldLabel); + WebElement gridCell = getCell(row, columnIdentifier); WebElement textArea = Locator.tag("textarea").refindWhenNeeded(gridCell); // Account for the cell already being active. @@ -598,7 +643,7 @@ public WebElement activateCellUsingDoubleClick(int row, String fieldLabel) { getWrapper().doubleClick(gridCell); waitFor(textArea::isDisplayed, - String.format("Table cell for row %d and column '%s' was not activated.", row, fieldLabel), 1_000); + String.format("Table cell for row %d and column '%s' was not activated.", row, columnIdentifier), 1_000); } return textArea; } @@ -606,12 +651,12 @@ public WebElement activateCellUsingDoubleClick(int row, String fieldLabel) /** * Creates a value in a select that allows the user to insert/create a value, vs. selecting from an existing/populated set * @param row the row - * @param fieldLabel label of the column + * @param columnIdentifier fieldKey, name, or label of column * @param value value to insert */ - public void setNewSelectValue(int row, String fieldLabel, String value) + public void setNewSelectValue(int row, CharSequence columnIdentifier, String value) { - WebElement gridCell = selectCell(row, fieldLabel); + WebElement gridCell = selectCell(row, columnIdentifier); ReactSelect createSelect = elementCache().lookupSelect(gridCell); @@ -619,26 +664,26 @@ public void setNewSelectValue(int row, String fieldLabel, String value) } /** - * Search for a row and then clear the given cell (fieldLabelToClear) on the row. + * Search for a row and then clear the given cell (columnToClear) on the row. * - * @param fieldLabelToSearch Column to search. + * @param columnToSearch Column to search. * @param valueToSearch Value in the column to search for. - * @param fieldLabelToClear Column to clear. + * @param columnToClear Column to clear. */ - public void clearCellValue(String fieldLabelToSearch, String valueToSearch, String fieldLabelToClear) + public void clearCellValue(CharSequence columnToSearch, String valueToSearch, CharSequence columnToClear) { - clearCellValue(getRowIndex(fieldLabelToSearch, valueToSearch), fieldLabelToClear); + clearCellValue(getRowIndex(columnToSearch, valueToSearch), columnToClear); } /** - * Clear the cell (fieldLabel) in the row. + * Clear the cell (columnIdentifier) in the row. * * @param row Row of the cell to clear. - * @param fieldLabel Column of the cell to clear. + * @param columnIdentifier fieldKey, name, or label of column */ - public void clearCellValue(int row, String fieldLabel) + public void clearCellValue(int row, CharSequence columnIdentifier) { - selectCell(row, fieldLabel); + selectCell(row, columnIdentifier); new Actions(getDriver()).sendKeys(Keys.DELETE).perform(); } @@ -646,12 +691,12 @@ public void clearCellValue(int row, String fieldLabel) * For a given row get the value in the given column. * * @param row The row index (0 based). - * @param fieldLabel The label of the column to get the value for. + * @param columnIdentifier fieldKey, name, or label of column * @return The string value of the {@link WebElement} that is the cell. */ - public String getCellValue(int row, String fieldLabel) + public String getCellValue(int row, CharSequence columnIdentifier) { - return getCellValue(getCell(row, fieldLabel)); + return getCellValue(getCell(row, columnIdentifier)); } private String getCellValue(WebElement cell) @@ -675,12 +720,12 @@ public EditableGrid dismissDropdownList() * For the given row get the values displayed in the dropdown list for the given column. * * @param row The 0 based row index. - * @param fieldLabel The label of the column. + * @param columnIdentifier fieldKey, name, or label of column * @return A list of strings from the dropdown list. If the cell does not have a dropdown then an empty list is returned. */ - public List getDropdownListForCell(int row, String fieldLabel) + public List getDropdownListForCell(int row, CharSequence columnIdentifier) { - return getFilteredDropdownListForCell(row, fieldLabel, null); + return getFilteredDropdownListForCell(row, columnIdentifier, null); } /** @@ -688,14 +733,14 @@ public List getDropdownListForCell(int row, String fieldLabel) * If this cell is not a lookup cell, does not have a dropdown, the text will not be entered and an empty list will be returned. * * @param row A 0 based index containing the cell. - * @param fieldLabel The column of the cell. + * @param columnIdentifier fieldKey, name, or label of column * @param filterText The text to type into the cell. If the value is null it will not filter the list. * @return A list values shown in the dropdown list after the text has been entered. */ - public List getFilteredDropdownListForCell(int row, String fieldLabel, @Nullable String filterText) + public List getFilteredDropdownListForCell(int row, CharSequence columnIdentifier, @Nullable String filterText) { - WebElement gridCell = selectCell(row, fieldLabel); + WebElement gridCell = selectCell(row, columnIdentifier); ReactSelect lookupSelect = elementCache().lookupSelect(gridCell); @@ -715,28 +760,28 @@ public List getFilteredDropdownListForCell(int row, String fieldLabel, @ * Pastes delimited text to the grid, via a single target. The component is clever enough to target * text into cells based on text delimiters; thus we can paste a square of data into the grid. * @param row index of the target cell - * @param fieldLabel column of the target cell + * @param columnIdentifier fieldKey, name, or label of column * @param pasteText tab-delimited or csv or excel data * @return A Reference to this editableGrid object. */ - public EditableGrid pasteFromCell(int row, String fieldLabel, String pasteText) + public EditableGrid pasteFromCell(int row, CharSequence columnIdentifier, String pasteText) { - return pasteFromCell(row, fieldLabel, pasteText, false); + return pasteFromCell(row, columnIdentifier, pasteText, false); } /** * Pastes delimited text to the grid, via a single target. The component is clever enough to target * text into cells based on text delimiters; thus we can paste a square of data into the grid. * @param row index of the target cell - * @param fieldLabel column of the target cell + * @param columnIdentifier fieldKey, name, or label of column * @param pasteText tab-delimited or csv or excel data * @param validate whether to await/confirm the presence of pasted text before resuming * @return A Reference to this editableGrid object. */ - public EditableGrid pasteFromCell(int row, String fieldLabel, String pasteText, boolean validate) + public EditableGrid pasteFromCell(int row, CharSequence columnIdentifier, String pasteText, boolean validate) { int initialRowCount = getRowCount(); - WebElement gridCell = getCell(row, fieldLabel); + WebElement gridCell = getCell(row, columnIdentifier); String indexValue = gridCell.getText(); selectCell(gridCell); @@ -775,17 +820,16 @@ protected void waitForAnyPasteContent(String pasteContent) public void waitForPasteContent(String pasteContent) { // split pasteContent into its parts - var contentParts = pasteContent.replace("\n", "\t").split("\t"); + var contentParts = pasteContent.split("\\s*[\n\t]\\s*"); // filter out empty and space-only values var filteredParts = Arrays.stream(contentParts) - .filter(a-> !a.isEmpty() && !a.equals(" ")) + .filter(a-> !a.isBlank()) .map(str -> { if (str.startsWith("\"") && str.endsWith("\"")) { // reverse TsvQuoter.quote str = str.replaceAll("\"\"", "\""); - str = str.substring(1); - str = str.substring(0, str.length() - 1); + str = str.substring(1, str.length() - 1); // remove surrounding quotes } return str; }) @@ -815,12 +859,12 @@ public List getSelectedCells() * the grid should produce an error/alert. * @param pasteText The text to paste * @param startRowIndex index of the starting row - * @param startColumn text of the starting cell + * @param startColumn fieldKey, name, or label of the starting cell * @param endRowIndex index of the ending row - * @param endColumn text of the ending cell + * @param endColumn fieldKey, name, or label of the ending cell * @return the current grid instance */ - public EditableGrid pasteMultipleCells(String pasteText, int startRowIndex, String startColumn, int endRowIndex, String endColumn) + public EditableGrid pasteMultipleCells(String pasteText, int startRowIndex, CharSequence startColumn, int endRowIndex, CharSequence endColumn) { WebElement startCell = getCell(startRowIndex, startColumn); WebElement endCell = getCell(endRowIndex, endColumn); @@ -832,12 +876,12 @@ public EditableGrid pasteMultipleCells(String pasteText, int startRowIndex, Stri /** * Copies text from the grid, b * @param startRowIndex Index of the top-left cell's row - * @param startColumn Column label of the top-left cell + * @param startColumn fieldKey, name, or label of the top-left cell * @param endRowIndex Index of the bottom-right cell's row - * @param endColumn Column label of the bottom-right cell + * @param endColumn fieldKey, name, or label of the bottom-right cell * @return the text contained in the prescribed selection */ - public String copyCellRange(int startRowIndex, String startColumn, int endRowIndex, String endColumn) throws IOException, UnsupportedFlavorException + public String copyCellRange(int startRowIndex, CharSequence startColumn, int endRowIndex, CharSequence endColumn) throws IOException, UnsupportedFlavorException { WebElement startCell = getCell(startRowIndex, startColumn); WebElement endCell = getCell(endRowIndex, endColumn); @@ -935,10 +979,10 @@ private void selectAllCells() "the expected cells did not become selected", 3000); } - public WebElement selectCell(int row, String fieldLabel) + public WebElement selectCell(int row, CharSequence columnIdentifier) { // Get a reference to the cell. - WebElement gridCell = getCell(row, fieldLabel); + WebElement gridCell = getCell(row, columnIdentifier); // Select the cell. selectCell(gridCell); @@ -992,14 +1036,14 @@ public boolean isCellSelected(WebElement cell) // div will not have the cell-selected in the class attribute. return Locator.tagWithClass("div", "cellular-display") .findElement(cell) - .getAttribute("class").contains("cell-selected"); + .getDomAttribute("class").contains("cell-selected"); } catch (NoSuchElementException nse) { // If the cell is an open/active reactSelect the class attribute is different. return Locator.tagWithClass("div", "select-input__control") .findElement(cell) - .getAttribute("class").contains("select-input__control--is-focused"); + .getDomAttribute("class").contains("select-input__control--is-focused"); } } @@ -1013,7 +1057,7 @@ private boolean isInSelection(WebElement cell) // 'in selection' shows as blue // Should not need to add code for a reactSelect here. A selection involves clicking/dragging, which closes the reactSelect. return Locator.tagWithClass("div", "cellular-display") .findElement(cell) - .getAttribute("class").contains("cell-selection"); + .getDomAttribute("class").contains("cell-selection"); } /** @@ -1030,9 +1074,9 @@ private boolean areAllInSelection() return (isInSelection(indexCell) && isInSelection(endCell)); } - public boolean hasCellError(int row, String fieldLabel) + public boolean hasCellError(int row, CharSequence columnIdentifier) { - WebElement gridCell = getCell(row, fieldLabel); + WebElement gridCell = getCell(row, columnIdentifier); return cellHasError(gridCell); } @@ -1041,18 +1085,28 @@ private boolean cellHasError(WebElement cell) return Locator.tagWithClass("div", "cell-error").existsIn(cell); } - public String getCellError(int row, String fieldLabel) + /** + * @param row row index + * @param columnIdentifier fieldKey, name, or label of column + * @return error text in the specified cell or 'null' if there is no error + */ + public String getCellError(int row, CharSequence columnIdentifier) { - WebElement gridCell = getCell(row, fieldLabel); + WebElement gridCell = getCell(row, columnIdentifier); if (cellHasError(gridCell)) return Locator.tagWithClass("div", "cell-error").findElement(gridCell).getText(); return null; } - public String getCellPopoverText(int row, String fieldLabel) + /** + * @param row row index + * @param columnIdentifier fieldKey, name, or label of column + * @return popover text when mousing over the specified cell or 'null' if there is none + */ + public String getCellPopoverText(int row, CharSequence columnIdentifier) { - WebElement cellDiv = Locator.tagWithClass("div", "cellular-display").findElement(getCell(row, fieldLabel)); + WebElement cellDiv = Locator.tagWithClass("div", "cellular-display").findElement(getCell(row, columnIdentifier)); getWrapper().mouseOver(cellDiv); // cause the tooltip to be present if (WebDriverWrapper.waitFor(()-> null != Locator.byClass("popover").findElementOrNull(getDriver()), 1000)) { @@ -1086,12 +1140,12 @@ public void setAddRows(int count) public void addRows(int count) { setAddRows(count); - doAndWaitForUpdate(() -> { + doAndWaitForRowCountUpdate(() -> { elementCache().addRowsButton.click(); }); } - private void doAndWaitForUpdate(Runnable func) + private void doAndWaitForRowCountUpdate(Runnable func) { int initialCount = getRowCount(); @@ -1100,6 +1154,21 @@ private void doAndWaitForUpdate(Runnable func) waitFor(() -> getRowCount() != initialCount, "Failed to add/remove rows", 5_000); } + /** + * Wait for column count to change after the provided action + */ + public void doAndWaitForColumnUpdate(Runnable func) + { + int initialCount = elementCache().findHeaders().size(); + + func.run(); + + waitFor(() -> { + clearElementCache(); + return elementCache().findHeaders().size() != initialCount; + }, "Failed to add/remove column", 5_000); + } + @Override protected ElementCache newElementCache() { @@ -1116,73 +1185,54 @@ protected class ElementCache extends Component.ElementCache final WebElement table = Locator.byClass("table-cellular").findWhenNeeded(this); private final WebElement selectColumn = Locator.xpath("//th/input[@type='checkbox']").findWhenNeeded(table); - public WebElement getGridCellHeader(String label) + protected WebElement getColumnHeaderCell(CharSequence columnIdentifier) { - return Locator.byClass("grid-header-cell").withDescendant(Locator.tagContainingText("span", label)).findWhenNeeded(table); + return findColumnHeader(columnIdentifier).getElement(); } - private List fieldLabels = new ArrayList<>(); - - public List getColumnLabels() + private FieldReferenceManager _fieldReferenceManager; + protected FieldReferenceManager getGridHeaderManager() { - // If the number of header cells is not equal to the list of fieldLabels the columns have been modified since - // the last call to getColumnLabels so get the column labels again. - List headerCells = Locators.headerCells.waitForElements(table, WAIT_FOR_JAVASCRIPT); - if (fieldLabels.size() != headerCells.size()) + if (_fieldReferenceManager == null) { - fieldLabels = new ArrayList<>(); - } + List columnHeaders = new ArrayList<>(); + List headerCellElements = Locators.headerCells.waitForElements(table, WAIT_FOR_JAVASCRIPT); + int domIndex = 0; - if (fieldLabels.isEmpty()) - { - for (WebElement el : headerCells) - { - fieldLabels.add(getLabelFromHeaderCell(el)); - } - - int rowNumberColumn = 0; if (hasSelectColumn()) { - fieldLabels.set(0, SELECT_COLUMN_HEADER); - rowNumberColumn = 1; + columnHeaders.add(new EditableGridColumnHeader(headerCellElements.get(0), domIndex, SELECT_COLUMN_LABEL_PLACEHOLDER)); + domIndex++; } - if (fieldLabels.get(rowNumberColumn).trim().isEmpty()) + for (; domIndex < headerCellElements.size(); domIndex++) { - fieldLabels.set(rowNumberColumn, ROW_NUMBER_COLUMN_HEADER); + columnHeaders.add(new EditableGridColumnHeader(headerCellElements.get(domIndex), domIndex)); } + + _fieldReferenceManager = new FieldReferenceManager(columnHeaders); } + return _fieldReferenceManager; + } - return fieldLabels; + protected List findHeaders() + { + return getGridHeaderManager().getColumnHeaders(); } - /** - * Extract label from header cell. Editable grid header cells have several different layouts. What they have in - * common is that the label is the first text node in the cell, possibly within a <span> - */ - private String getLabelFromHeaderCell(WebElement el) + protected FieldReference findColumnHeader(CharSequence columnIdentifier) { - // Use text nodes to ignore browser whitespace formatting - List textNodes = WebElementUtils.getTextNodesWithin(el); - if (textNodes.isEmpty()) - { - List children = Locator.xpath("./*").findElements(el); - if (children.isEmpty()) - { - return ""; // probably the selection checkbox column - } - else - { - // Depth-first search until we find some text - return getLabelFromHeaderCell(children.get(0)); - } - } - else - { - boolean required = Locator.byClass("required-symbol").existsIn(el); - String label = textNodes.get(0).trim(); // trim trailing NBSP - return label + (required ? " *" : ""); // re-add required asterisk for tests that expect it - } + return getGridHeaderManager().findFieldReference(columnIdentifier); + } + + protected int getColumnIndex(CharSequence columnIdentifier) + { + return findColumnHeader(columnIdentifier).getDomIndex(); + } + + protected List getColumnLabels() + { + return findHeaders().stream().map(FieldReference::getLabel).collect(Collectors.toList()); } public WebElement inputCell() @@ -1244,4 +1294,60 @@ protected Locator locator() return _locator; } } + + protected static class EditableGridColumnHeader extends FieldReferenceManager.FieldReference + { + private final Mutable _fieldLabel = new MutableObject<>(); + + public EditableGridColumnHeader(WebElement element, int domIndex) + { + super(element, domIndex); + } + + public EditableGridColumnHeader(WebElement element, int domIndex, String label) + { + this(element, domIndex); + _fieldLabel.setValue(label); + } + + @Override + public String getLabel() + { + if (_fieldLabel.getValue() == null) + { + _fieldLabel.setValue(getLabelFromHeaderCell(getElement()).trim()); + } + return _fieldLabel.getValue(); + } + + + /** + * Extract label from header cell. Editable grid header cells have several different layouts. What they have in + * common is that the label is the first text node in the cell, possibly within a <span> + */ + private String getLabelFromHeaderCell(WebElement el) + { + // Use text nodes to ignore browser whitespace formatting + List textNodes = WebElementUtils.getTextNodesWithin(el); + if (textNodes.isEmpty()) + { + List children = Locator.xpath("./*").findElements(el); + if (children.isEmpty()) + { + return ""; // probably the selection checkbox column + } + else + { + // Depth-first search until we find some text + return getLabelFromHeaderCell(children.get(0)); + } + } + else + { + boolean required = Locator.byClass("required-symbol").existsIn(el); + String label = textNodes.get(0).trim(); // trim trailing NBSP + return label + (required ? " *" : ""); // re-add required asterisk for tests that expect it + } + } + } } diff --git a/src/org/labkey/test/components/ui/grids/FieldReferenceManager.java b/src/org/labkey/test/components/ui/grids/FieldReferenceManager.java new file mode 100644 index 0000000000..b8845cf93b --- /dev/null +++ b/src/org/labkey/test/components/ui/grids/FieldReferenceManager.java @@ -0,0 +1,176 @@ +package org.labkey.test.components.ui.grids; + +import org.apache.commons.lang3.mutable.Mutable; +import org.apache.commons.lang3.mutable.MutableObject; +import org.labkey.test.params.FieldKey; +import org.labkey.test.util.selenium.WebElementUtils; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebElement; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +public class FieldReferenceManager +{ + private final List _fieldReferences; + private final Map fieldKeys = new LinkedHashMap<>(); + private final Map fieldLabels = new LinkedHashMap<>(); + + public FieldReferenceManager(List columnHeaders) + { + this._fieldReferences = Collections.unmodifiableList(columnHeaders); + } + + public List getColumnHeaders() + { + return _fieldReferences; + } + + /** + * Find field by uncertain field identifier in order of precedence: + *
    + *
  1. FieldKey object
  2. + *
  3. Encoded fieldKey
  4. + *
  5. Unencoded fieldKey
  6. + *
  7. Field Label
  8. + *
+ */ + public final FieldReference findFieldReference(CharSequence fieldIdentifier) + { + List> options; + + if (fieldIdentifier instanceof FieldKey fk) + { + options = List.of(() -> findColumnHeaderByFieldKey(fk)); // We know it is a FieldKey + } + else + { + options = List.of( + () -> findColumnHeaderByFieldKey(FieldKey.fromFieldKey(fieldIdentifier)), // encoded fieldKey + () -> findColumnHeaderByFieldKey(FieldKey.fromName(fieldIdentifier)), // unencoded fieldKey + () -> findColumnHeaderByLabel(fieldIdentifier.toString()) // Field label + ); + } + + return options.stream() + .map(Supplier::get) + .filter(Objects::nonNull) + .findFirst() + .orElseThrow(() -> new NoSuchElementException("Unable to locate field: " + fieldIdentifier)); + } + + private FieldReference findColumnHeaderByFieldKey(FieldKey fieldIdentifier) + { + if (fieldKeys.containsKey(fieldIdentifier)) + { + return fieldKeys.get(fieldIdentifier); + } + else if (fieldKeys.size() < _fieldReferences.size()) + { + for (FieldReference header : _fieldReferences) + { + if (!fieldKeys.containsValue(header)) + { + FieldKey fieldKey = header.getFieldKey(); + fieldKeys.put(fieldKey, header); + if (fieldKey.equals(fieldIdentifier)) + { + return header; + } + } + } + } + + return null; + } + + private FieldReference findColumnHeaderByLabel(String label) + { + if (fieldLabels.containsKey(label)) + { + return fieldLabels.get(label); + } + else if (fieldLabels.size() < _fieldReferences.size()) + { + for (FieldReference header : _fieldReferences) + { + if (!fieldLabels.containsValue(header)) + { + String columnLabel = header.getLabel(); + fieldLabels.put(columnLabel, header); + if (columnLabel.equals(label)) + { + return header; + } + } + } + } + + return null; + } + + public static class FieldReference + { + private final WebElement _element; + private final int _domIndex; + private final Mutable _fieldLabel = new MutableObject<>(); + private final Mutable _fieldKey = new MutableObject<>(); + + public FieldReference(WebElement element, int domIndex) + { + _element = element; + _domIndex = domIndex; + } + + public WebElement getElement() + { + return _element; + } + + public FieldKey getFieldKey() + { + if (_fieldKey.getValue() == null) + { + String path = _element.getDomAttribute("data-fieldkey"); + if (path == null) + { + // Some grids don't have a field key, but have a similar value in the ID attribute + path = _element.getDomAttribute("id"); + } + + if (path != null) + { + _fieldKey.setValue(FieldKey.fromFieldKey(path)); + } + else + { + _fieldKey.setValue(FieldKey.EMPTY); + } + } + return _fieldKey.getValue(); + } + + public String getLabel() + { + if (_fieldLabel.getValue() == null) + { + _fieldLabel.setValue(WebElementUtils.getTextContent(getElement()).trim()); + } + return _fieldLabel.getValue(); + } + + public String getName() + { + return getFieldKey().getName(); + } + + public int getDomIndex() + { + return _domIndex; + } + } +} diff --git a/src/org/labkey/test/components/ui/grids/FieldSelectionDialog.java b/src/org/labkey/test/components/ui/grids/FieldSelectionDialog.java index 115fdf49df..c7c01bbcae 100644 --- a/src/org/labkey/test/components/ui/grids/FieldSelectionDialog.java +++ b/src/org/labkey/test/components/ui/grids/FieldSelectionDialog.java @@ -7,9 +7,10 @@ import org.labkey.test.components.UpdatingComponent; import org.labkey.test.components.bootstrap.ModalDialog; import org.labkey.test.components.html.Checkbox; -import org.labkey.test.util.EscapeUtil; +import org.labkey.test.params.FieldKey; import org.labkey.test.util.selenium.WebElementUtils; import org.openqa.selenium.Keys; +import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.interactions.Actions; @@ -17,7 +18,6 @@ import org.openqa.selenium.support.ui.WebDriverWait; import java.time.Duration; -import java.util.Arrays; import java.util.Iterator; import java.util.List; import java.util.stream.Collectors; @@ -103,13 +103,31 @@ public List getAvailableFieldLabels() */ public boolean isAvailableFieldSelected(String... fieldNameParts) { - WebElement listItem = elementCache().getListItemElementByFieldKey(expandAvailableFields(fieldNameParts)); + return isAvailableFieldSelected(FieldKey.fromParts(fieldNameParts)); + } + + public boolean isAvailableFieldSelected(FieldKey fieldKey) + { + WebElement listItem = getAvailableFieldElement(fieldKey); return Locator.tagWithClass("i", "fa-check").findWhenNeeded(listItem).isDisplayed(); } public boolean isFieldAvailable(String... fieldNameParts) { - return elementCache().getListItemElementByFieldKeyOrNull(expandAvailableFields(fieldNameParts)) != null; + return isFieldAvailable(FieldKey.fromParts(fieldNameParts)); + } + + public boolean isFieldAvailable(FieldKey fieldKey) + { + try + { + getAvailableFieldElement(fieldKey); + return true; + } + catch (NoSuchElementException e) + { + return false; + } } /** @@ -120,27 +138,15 @@ public boolean isFieldAvailable(String... fieldNameParts) */ public FieldSelectionDialog selectAvailableField(String... fieldNameParts) { - return addFieldByFieldKeyToGrid(expandAvailableFields(fieldNameParts)); + return selectAvailableField(FieldKey.fromParts(fieldNameParts)); } - public WebElement getAvailableFieldElement(String fieldName) + public FieldSelectionDialog selectAvailableField(FieldKey fieldKey) { - String fieldKey = expandAvailableFields(fieldName); - return elementCache().getListItemElementByFieldKey(fieldKey); - } - - /** - * Private helper to add a field to the 'Shown in Grid' list. Use the data-fieldkey value to identify the item. - * - * @param fieldKey The value in the data-fieldkay attribute for the row. - * @return This dialog. - */ - private FieldSelectionDialog addFieldByFieldKeyToGrid(String fieldKey) - { - WebElement listItem = elementCache().getListItemElementByFieldKey(fieldKey); + WebElement listItem = getAvailableFieldElement(fieldKey); Assert.assertTrue(String.format(FIELD_NOT_AVAILABLE, fieldKey), - listItem.isDisplayed()); + listItem.isDisplayed()); WebElement addIcon = Locator.tagWithClass("div", "view-field__action") .withChild(Locator.tagWithClass("i", "fa-plus")) @@ -151,36 +157,36 @@ private FieldSelectionDialog addFieldByFieldKeyToGrid(String fieldKey) return this; } + public WebElement getAvailableFieldElement(String... fieldNameParts) + { + return getAvailableFieldElement(FieldKey.fromParts(fieldNameParts)); + } + /** - * Expand a field or a hierarchy of fieldKeyParts. If a single field is passed in only it will be expanded. If multiple values - * are passed in it is assumed to be a path and all fieldKeyParts will be expanded to the last field. + * Expand available field tree to the specified field * - * @param fieldNameParts The list of fieldKeyParts to expand. - * @return key for the expanded field. + * @param fieldKey FieldKey for the target field + * @return row element for the specified field */ - private String expandAvailableFields(String... fieldNameParts) + public WebElement getAvailableFieldElement(FieldKey fieldKey) { - StringBuilder fieldKey = new StringBuilder(); - - Iterator iterator = Arrays.stream(fieldNameParts).iterator(); + Iterator iterator = fieldKey.getIterator(); while(iterator.hasNext()) { - fieldKey.append(EscapeUtil.fieldKeyEncodePart(iterator.next().trim())); + fieldKey = iterator.next(); // If this isn't the last item in the collection keep expanding and building the expected data-fieldkey value. if(iterator.hasNext()) { // If the field is already expanded don't try to expand it. - if(!isFieldKeyExpanded(elementCache().getListItemElementByFieldKey(fieldKey.toString()))) + if(!isFieldKeyExpanded(elementCache().findAvailableField(fieldKey.toString()))) expandOrCollapseByFieldKey(fieldKey.toString(), true); - - fieldKey.append("/"); } } - return fieldKey.toString(); + return elementCache().findAvailableField(fieldKey.toString()); } /** @@ -192,7 +198,7 @@ private String expandAvailableFields(String... fieldNameParts) private void expandOrCollapseByFieldKey(String fieldKey, boolean expand) { - WebElement listItem = elementCache().getListItemElementByFieldKey(fieldKey); + WebElement listItem = elementCache().findAvailableField(fieldKey); // Check to see if row is already in the desired state. If so don't do anything. if((expand && isFieldKeyExpanded(listItem) || (!expand && !isFieldKeyExpanded(listItem)))) @@ -409,33 +415,30 @@ public FieldSelectionDialog removeAllSelectedFields() /** * Update the given field label to a new value. * - * @param currentFieldLabel The field to be updated. + * @param fieldName The field to be updated. * @param newFieldLabel The new value to set the label to. * @return This dialog. */ - public FieldSelectionDialog setFieldLabel(String currentFieldLabel, String newFieldLabel) + public FieldSelectionDialog setFieldLabel(String fieldName, String newFieldLabel) { - return setFieldLabel(currentFieldLabel, 0, newFieldLabel); + return setFieldLabel(FieldKey.fromParts(fieldName), newFieldLabel); } /** - * Update the given field to a new label. If there are multiple fields with the same label in the list the index - * parameter identifies which one to update. + * Update the given field to a new label. * - * @param currentFieldLabel The field to be updated. - * @param index If multiple fields have the save label this identifies which one in the list to update. + * @param fieldKey The field to be updated. * @param newFieldLabel The new value to set the label to. * @return This dialog. */ - public FieldSelectionDialog setFieldLabel(String currentFieldLabel, int index, String newFieldLabel) + public FieldSelectionDialog setFieldLabel(FieldKey fieldKey, String newFieldLabel) { - - WebElement listItem = getSelectedListItems(currentFieldLabel).get(index); + WebElement listItem = elementCache().findSelectedField(fieldKey.toString()); WebElement updateIcon = Locator.tagWithClass("span", "edit-inline-field__toggle").findWhenNeeded(listItem); updateIcon.click(); WebDriverWrapper.waitFor(()->elementCache().fieldLabelEdit.isDisplayed(), - String.format("Input for field '%s' was not shown.", currentFieldLabel), 1_500); + String.format("Input for field '%s' was not shown.", fieldKey), 1_500); // Unfortunately using setFormElement doesn't work in this case. That method calls WebElement.clear which clears // the current text but also causes the focus to the input control to be lost. When the focus is lost the input @@ -448,6 +451,8 @@ public FieldSelectionDialog setFieldLabel(String currentFieldLabel, int index, S .sendKeys(Keys.TAB) .perform(); + getWrapper().mouseOver(elementCache().title); // Dismiss tooltip + WebDriverWrapper.waitFor(()->!elementCache().fieldLabelEdit.isDisplayed() && elementCache().getListItemElement(elementCache().selectedFieldsPanel, newFieldLabel).isDisplayed(), String.format("New field label '%s' is not in the list.", newFieldLabel), 500); @@ -636,20 +641,21 @@ protected WebElement getListItemElement(WebElement panel, String fieldLabel) .findElement(panel); } - // The data-fieldkey attribute is only present in items in the Available Fields panel. - // Similar value to field-name (no spaces, but casing is the same). For child fields it will contain the parent path. - protected WebElement getListItemElementByFieldKey(String fieldKey) + protected WebElement findSelectedField(String fieldKey) { - return Locator.tagWithClass("div", "list-group-item") - .withAttributeIgnoreCase("data-fieldkey", fieldKey) - .findElement(availableFieldsPanel); + return findFieldRow(fieldKey, selectedFieldsPanel); + } + + protected WebElement findAvailableField(String fieldKey) + { + return findFieldRow(fieldKey, availableFieldsPanel); } - protected WebElement getListItemElementByFieldKeyOrNull(String fieldKey) + protected WebElement findFieldRow(String fieldKey, WebElement panel) { return Locator.tagWithClass("div", "list-group-item") - .withAttributeIgnoreCase("data-fieldkey", fieldKey) - .findElementOrNull(availableFieldsPanel); + .withAttributeIgnoreCase("data-fieldkey", fieldKey) + .findElement(panel); } // Get the displayed names/labels of list items in the given panel. diff --git a/src/org/labkey/test/components/ui/grids/GridRow.java b/src/org/labkey/test/components/ui/grids/GridRow.java index f2218323cd..932b3bf1bf 100644 --- a/src/org/labkey/test/components/ui/grids/GridRow.java +++ b/src/org/labkey/test/components/ui/grids/GridRow.java @@ -1,6 +1,5 @@ package org.labkey.test.components.ui.grids; -import org.labkey.api.collections.CaseInsensitiveHashMap; import org.labkey.test.Locator; import org.labkey.test.WebDriverWrapper; import org.labkey.test.components.Component; @@ -8,24 +7,28 @@ import org.labkey.test.components.react.ReactCheckBox; import org.labkey.test.components.ui.files.AttachmentCard; import org.labkey.test.components.ui.files.ImageFileViewDialog; +import org.labkey.test.components.ui.grids.FieldReferenceManager.FieldReference; +import org.labkey.test.params.FieldKey; import org.labkey.test.util.LogMethod; import org.labkey.test.util.LoggedParam; +import org.labkey.test.util.TestLogger; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.ui.ExpectedConditions; import java.io.File; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Function; import static org.junit.Assert.assertTrue; import static org.labkey.test.WebDriverWrapper.WAIT_FOR_JAVASCRIPT; public class GridRow extends WebDriverComponent { - final WebElement _el; - final ResponsiveGrid _grid; - private Map _rowMapByLabel = null; + private final WebElement _el; + protected final ResponsiveGrid _grid; protected GridRow(ResponsiveGrid grid, WebElement element) { @@ -73,20 +76,18 @@ public GridRow select(boolean checked) /** * gets the cell at the specified index - * use fieldLabel, which computes the appropriate index - * This method is intended for short-term use, until we can offload usages in the heatmap to its own component */ public WebElement getCell(int colIndex) { - return Locator.tag("td").index(colIndex).findElement(this); + return elementCache().findCells().get(colIndex); } /** * gets the cell corresponding to the specified column */ - public WebElement getCell(String fieldLabel) + public WebElement getCell(CharSequence columnIdentifier) { - return getCell(_grid.getColumnIndex(fieldLabel)); + return getCell(_grid.getColumnIndex(columnIdentifier)); } /** @@ -128,25 +129,25 @@ public void clickLinkWithTitle(String text) /** * finds a AttachmentCard in the specified column, clicks it, and waits for the image to display in a modal */ - public ImageFileViewDialog clickImgFile(String fieldLabel) + public ImageFileViewDialog clickImgFile(CharSequence columnIdentifier) { - return elementCache().waitForAttachment(fieldLabel).viewImgFile(); + return elementCache().waitForAttachment(columnIdentifier).viewImgFile(); } /** * finds a AttachmentCard specified filename, clicks it, and waits for the file to download */ - public File clickNonImgFile(String fieldLabel) + public File clickNonImgFile(CharSequence columnIdentifier) { - return elementCache().waitForAttachment(fieldLabel).clickOnNonImgFile(); + return elementCache().waitForAttachment(columnIdentifier).clickOnNonImgFile(); } /** * Returns the text in the row for the specified column */ - public String getText(String fieldLabel) + public String getText(CharSequence columnIdentifier) { - return getCell(fieldLabel).getText(); + return getCell(columnIdentifier).getText(); } /** @@ -154,8 +155,7 @@ public String getText(String fieldLabel) */ public List getTexts() { - List columnValues = getWrapper().getTexts(Locator.css("td") - .findElements(this)); + List columnValues = elementCache().getCellTexts(); if (hasSelectColumn()) columnValues.remove(0); return columnValues; @@ -166,17 +166,48 @@ public List getTexts() */ public Map getRowMapByLabel() { - if (_rowMapByLabel == null) + return getRowMap(FieldReference::getLabel); + } + + /** + * gets a map of the row's values, keyed by column name + */ + public Map getRowMapByName() + { + return getRowMap(FieldReference::getName); + } + + /** + * gets a map of the row's values, keyed by column fieldKey + */ + public Map getRowMapByFieldKey() + { + return getRowMap(FieldReference::getFieldKey); + } + + Map getRowMap(Function keyMapper) + { + List columnValues = elementCache().getCellTexts(); + List headers = _grid.getHeaders(); + + Map rowMap = new LinkedHashMap<>(); + + for (FieldReference header : headers) { - _rowMapByLabel = new CaseInsensitiveHashMap<>(); - List columns = _grid.getColumnLabels(); - List rowCellTexts = getTexts(); - for (int i = 0; i < columns.size(); i++) + T key = keyMapper.apply(header); + String value = columnValues.get(header.getDomIndex()); + + if (rowMap.containsKey(key)) + { + TestLogger.warn("Column identifier '%s' is ambiguous, omitting value '%s', consider getting data by name or fieldKey (e.g. %s)".formatted(key, value, header.getFieldKey())); + } + else { - _rowMapByLabel.put(columns.get(i), rowCellTexts.get(i)); + rowMap.put(key, value); } } - return _rowMapByLabel; + + return rowMap; } @Override @@ -202,9 +233,29 @@ protected class ElementCache extends Component.ElementCache public ReactCheckBox selectCheckbox = new ReactCheckBox(Locator.tagWithAttribute("input", "type", "checkbox") .findWhenNeeded(this)); - public AttachmentCard waitForAttachment(String fieldLabel) + private List _cells = null; + protected List findCells() + { + if (_cells == null) + { + _cells = Locator.xpath("./td").findElements(this); + } + return _cells; + } + + private List cellTexts = null; + protected List getCellTexts() + { + if (cellTexts == null) + { + cellTexts = getWrapper().getTexts(findCells()); + } + return cellTexts; + } + + public AttachmentCard waitForAttachment(CharSequence columnIdentifier) { - return new AttachmentCard.FileAttachmentCardFinder(getDriver()).waitFor(getCell(fieldLabel)); + return new AttachmentCard.FileAttachmentCardFinder(getDriver()).waitFor(getCell(columnIdentifier)); } } diff --git a/src/org/labkey/test/components/ui/grids/QueryGrid.java b/src/org/labkey/test/components/ui/grids/QueryGrid.java index 78e6eb21eb..9775a4666d 100644 --- a/src/org/labkey/test/components/ui/grids/QueryGrid.java +++ b/src/org/labkey/test/components/ui/grids/QueryGrid.java @@ -79,14 +79,13 @@ public Map getRowMapByLabel(String text) /** * Returns the first row with the supplied text in the specified column - * @param fieldLabel The text in the column header cell + * @param columnIdentifier The text in the column header cell * @param text text in the data cell * @return row data */ - public Map getRowMapByLabel(String fieldLabel, String text) + public Map getRowMapByLabel(CharSequence columnIdentifier, String text) { - GridRow row = getRow(fieldLabel, text); - return row.getRowMapByLabel(); + return getRow(columnIdentifier, text).getRowMapByLabel(); } /** @@ -113,15 +112,15 @@ public Map getRowMapByLabel(Locator.XPathLocator containing) /** * Selects or un-selects the first row with the specified text in the specified column - * @param fieldLabel The exact text of the column header + * @param columnIdentifier The exact text of the column header * @param text The full text of the cell to match * @param checked whether or not to check the box * @return this grid */ @Override - public QueryGrid selectRow(String fieldLabel, String text, boolean checked) + public QueryGrid selectRow(CharSequence columnIdentifier, String text, boolean checked) { - getRow(fieldLabel, text).select(checked); + getRow(columnIdentifier, text).select(checked); return this; } @@ -225,7 +224,10 @@ public void doAndWaitForUpdate(Runnable func) func.run(); - optionalStatus.ifPresent(el -> getWrapper().shortWait().until(ExpectedConditions.stalenessOf(el))); + optionalStatus.ifPresent(el -> { + getWrapper().shortWait().until(ExpectedConditions.stalenessOf(el)); + elementCache().selectionStatusContainerLoc.waitForElement(this, 5_000); + }); waitForLoaded(); clearElementCache(); @@ -535,7 +537,7 @@ public boolean isManageViewsEnabled() for(WebElement menuItem : menuItems) { // Why does menuItem.getText() return an empty string here? - if(menuItem.getAttribute("text").contains("Manage Saved Views")) + if(menuItem.getDomProperty("text").contains("Manage Saved Views")) return false; } diff --git a/src/org/labkey/test/components/ui/grids/ResponsiveGrid.java b/src/org/labkey/test/components/ui/grids/ResponsiveGrid.java index 70d7aefab9..2fd6fba318 100644 --- a/src/org/labkey/test/components/ui/grids/ResponsiveGrid.java +++ b/src/org/labkey/test/components/ui/grids/ResponsiveGrid.java @@ -14,10 +14,11 @@ import org.labkey.test.components.WebDriverComponent; import org.labkey.test.components.html.RadioButton; 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.params.FieldKey; import org.labkey.test.util.selenium.WebElementUtils; import org.openqa.selenium.Keys; -import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.NotFoundException; import org.openqa.selenium.StaleElementReferenceException; import org.openqa.selenium.WebDriver; @@ -27,7 +28,7 @@ import java.util.ArrayList; import java.util.Collection; -import java.util.HashMap; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; @@ -38,7 +39,7 @@ import static org.junit.Assert.assertEquals; import static org.labkey.test.WebDriverWrapper.waitFor; -public class ResponsiveGrid extends WebDriverComponent.ElementCache> implements UpdatingComponent +public class ResponsiveGrid> extends WebDriverComponent.ElementCache> implements UpdatingComponent { final WebElement _gridElement; final WebDriver _driver; @@ -99,7 +100,7 @@ public boolean hasLockedColumn() { return Locator.tagWithClass("div", "grid-panel__grid") .findElement(getComponentElement()) - .getAttribute("class").contains("grid-panel__lock-left"); + .getDomAttribute("class").contains("grid-panel__lock-left"); } /** @@ -113,39 +114,49 @@ public void scrollToOrigin() /** * Sorts from the grid header menu - * @param fieldLabel column header for + * @param columnIdentifier fieldKey, name, or label of column * @return this grid */ - public T sortColumnAscending(String fieldLabel) + public T sortColumnAscending(CharSequence columnIdentifier) { - sortColumn(fieldLabel, SortDirection.ASC); + sortColumn(columnIdentifier, SortDirection.ASC); return getThis(); } /** * Sorts from the grid header menu - * @param fieldLabel Text of column + * @param columnIdentifier fieldKey, name, or label of column * @return this grid */ - public T sortColumnDescending(String fieldLabel) + public T sortColumnDescending(CharSequence columnIdentifier) { - sortColumn(fieldLabel, SortDirection.DESC); + sortColumn(columnIdentifier, SortDirection.DESC); return getThis(); } - public void sortColumn(String fieldLabel, SortDirection direction) + /** + * Sorts from the grid header menu + * @param columnIdentifier fieldKey, name, or label of column + */ + public void sortColumn(CharSequence columnIdentifier, SortDirection direction) { - clickColumnMenuItem(fieldLabel, direction.equals(SortDirection.DESC) ? "Sort descending" : "Sort ascending", true); + clickColumnMenuItem(columnIdentifier, direction.equals(SortDirection.DESC) ? "Sort descending" : "Sort ascending", true); } - public void clearSort(String fieldLabel) + /** + * @param columnIdentifier fieldKey, name, or label of column + */ + public void clearSort(CharSequence columnIdentifier) { - clickColumnMenuItem(fieldLabel, "Clear sort", true); + clickColumnMenuItem(columnIdentifier, "Clear sort", true); } - public boolean hasColumnSortIcon(String fieldLabel) + /** + * @param columnIdentifier fieldKey, name, or label of column + */ + public boolean hasColumnSortIcon(CharSequence columnIdentifier) { - WebElement headerCell = elementCache().getColumnHeaderCell(fieldLabel); + WebElement headerCell = elementCache().getColumnHeaderCell(columnIdentifier); Optional colHeaderIcon = Locator.XPathLocator.union( Locator.tagWithClass("span", "grid-panel__col-header-icon").withClass("fa-sort-amount-asc"), Locator.tagWithClass("span", "grid-panel__col-header-icon").withClass("fa-sort-amount-desc") @@ -154,23 +165,32 @@ public boolean hasColumnSortIcon(String fieldLabel) } - public T filterColumn(String fieldLabel, Filter.Operator operator) + /** + * @param columnIdentifier fieldKey, name, or label of column + */ + public T filterColumn(CharSequence columnIdentifier, Filter.Operator operator) { - return filterColumn(fieldLabel, operator, null); + return filterColumn(columnIdentifier, operator, null); } - public T filterColumn(String fieldLabel, Filter.Operator operator, Object value) + /** + * @param columnIdentifier fieldKey, name, or label of column + */ + public T filterColumn(CharSequence columnIdentifier, Filter.Operator operator, Object value) { T _this = getThis(); - doAndWaitForUpdate(()->initFilterColumn(fieldLabel, operator, value).confirm()); + doAndWaitForUpdate(()->initFilterColumn(columnIdentifier, operator, value).confirm()); return _this; } - public T filterColumn(String fieldLabel, Filter.Operator operator1, Object value1, Filter.Operator operator2, Object value2) + /** + * @param columnIdentifier fieldKey, name, or label of column + */ + public T filterColumn(CharSequence columnIdentifier, Filter.Operator operator1, Object value1, Filter.Operator operator2, Object value2) { T _this = getThis(); doAndWaitForUpdate(()-> { - GridFilterModal filterModal = initFilterColumn(fieldLabel, null, null); + GridFilterModal filterModal = initFilterColumn(columnIdentifier, null, null); filterModal.selectExpressionTab().setFilters( new FilterExpressionPanel.Expression(operator1, value1), new FilterExpressionPanel.Expression(operator2, value2) @@ -181,11 +201,14 @@ public T filterColumn(String fieldLabel, Filter.Operator operator1, Object value return _this; } - public T filterBooleanColumn(String fieldLabel, boolean value) + /** + * @param columnIdentifier fieldKey, name, or label of column + */ + public T filterBooleanColumn(CharSequence columnIdentifier, boolean value) { T _this = getThis(); doAndWaitForUpdate(() -> { - clickColumnMenuItem(fieldLabel, "Filter...", false); + clickColumnMenuItem(columnIdentifier, "Filter...", false); GridFilterModal filterModal = new GridFilterModal(getDriver(), this); new RadioButton.RadioButtonFinder().withNameAndValue("field-value-bool-0", value?"true":"false").find(filterModal).set(true); filterModal.confirm(); @@ -193,32 +216,41 @@ public T filterBooleanColumn(String fieldLabel, boolean value) return _this; } - public String filterColumnExpectingError(String fieldLabel, Filter.Operator operator, Object value) + /** + * @param columnIdentifier fieldKey, name, or label of column + */ + public String filterColumnExpectingError(CharSequence columnIdentifier, Filter.Operator operator, Object value) { - GridFilterModal filterModal = initFilterColumn(fieldLabel, operator, value); + GridFilterModal filterModal = initFilterColumn(columnIdentifier, operator, value); String errorMsg = filterModal.confirmExpectingError(); filterModal.cancel(); return errorMsg; } - private GridFilterModal initFilterColumn(String fieldLabel, Filter.Operator operator, Object value) + private GridFilterModal initFilterColumn(CharSequence columnIdentifier, Filter.Operator operator, Object value) { - clickColumnMenuItem(fieldLabel, "Filter...", false); + clickColumnMenuItem(columnIdentifier, "Filter...", false); GridFilterModal filterModal = new GridFilterModal(getDriver(), this); if (operator != null) filterModal.selectExpressionTab().setFilter(new FilterExpressionPanel.Expression(operator, value)); return filterModal; } - public T removeColumnFilter(String fieldLabel) + /** + * @param columnIdentifier fieldKey, name, or label of column + */ + public T removeColumnFilter(CharSequence columnIdentifier) { - clickColumnMenuItem(fieldLabel, "Remove filter", true); + clickColumnMenuItem(columnIdentifier, "Remove filter", true); return getThis(); } - public boolean hasColumnFilterIcon(String fieldLabel) + /** + * @param columnIdentifier fieldKey, name, or label of column + */ + public boolean hasColumnFilterIcon(CharSequence columnIdentifier) { - WebElement headerCell = elementCache().getColumnHeaderCell(fieldLabel); + WebElement headerCell = elementCache().getColumnHeaderCell(columnIdentifier); Optional colHeaderIcon = Locator.tagWithClass("span", "grid-panel__col-header-icon") .withClass("fa-filter") .findOptionalElement(headerCell); @@ -229,13 +261,13 @@ public boolean hasColumnFilterIcon(String fieldLabel) /** * use the column menu to hide the given column. * - * @param fieldLabel Column to hide. + * @param columnIdentifier fieldKey, name, or label of column * @return This grid. */ - public T hideColumn(String fieldLabel) + public T hideColumn(CharSequence columnIdentifier) { // Because this will remove the column wait for the grid to update. - clickColumnMenuItem(fieldLabel, "Hide Column", true); + clickColumnMenuItem(columnIdentifier, "Hide Column", true); return getThis(); } @@ -246,24 +278,24 @@ public T hideColumn(String fieldLabel) */ public FieldSelectionDialog insertColumn() { - return insertColumn(getColumnLabels().get(0)); + return insertColumn(getHeaders().get(0).getFieldKey().toString()); } /** * Use the column menu to show a Field Selection dialog {@link FieldSelectionDialog}. This will use the given column to * get the menu. This should insert the column after (to the right) of this column. * - * @param fieldLabel The column to get the menu from. + * @param columnIdentifier fieldKey, name, or label of column * @return A {@link FieldSelectionDialog} */ - public FieldSelectionDialog insertColumn(String fieldLabel) + public FieldSelectionDialog insertColumn(CharSequence columnIdentifier) { // Because this is going to show the customize grid dialog don't wait for a grid update. the dialog will wait for the update. - clickColumnMenuItem(fieldLabel, "Insert Column", false); + clickColumnMenuItem(columnIdentifier, "Insert Column", false); return new FieldSelectionDialog(getDriver(), this); } - protected void clickColumnMenuItem(String fieldLabel, String menuText, boolean waitForUpdate) + protected void clickColumnMenuItem(CharSequence columnIdentifier, String menuText, boolean waitForUpdate) { if(hasLockedColumn()) @@ -271,7 +303,7 @@ protected void clickColumnMenuItem(String fieldLabel, String menuText, boolean w scrollToOrigin(); } - WebElement headerCell = elementCache().getColumnHeaderCell(fieldLabel); + WebElement headerCell = elementCache().getColumnHeaderCell(columnIdentifier); // Scroll to middle in order to make room for the dropdown menu getWrapper().scrollToMiddle(headerCell); @@ -292,13 +324,17 @@ protected void clickColumnMenuItem(String fieldLabel, String menuText, boolean w waitFor(()-> !menuItem.isDisplayed(), 1000); } - public void editColumnLabel(String fieldLabel, String newColumnLabel) + /** + * @param columnIdentifier fieldKey, name, or label of column + * @param newColumnLabel new label for the column + */ + public void editColumnLabel(CharSequence columnIdentifier, String newColumnLabel) { // Get the column header. - WebElement headerCell = elementCache().getColumnHeaderCell(fieldLabel); + WebElement headerCell = elementCache().getColumnHeaderCell(columnIdentifier); // Select the edit menu. - clickColumnMenuItem(fieldLabel, "Edit Label", false); + clickColumnMenuItem(columnIdentifier, "Edit Label", false); // Get the textbox. WebElement textEdit = Locator.tag("input").findWhenNeeded(headerCell); @@ -341,7 +377,7 @@ 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, value-text in that column + * @param partialMap key-column (fieldKey, name, or label), value-text in that column * @param checked the desired checkbox state * @return this grid */ @@ -356,16 +392,16 @@ public T selectRow(Map partialMap, boolean checked) /** * Finds the first row with the specified text in the specified column and sets its checkbox - * @param fieldLabel header text of the specified column + * @param columnIdentifier fieldKey, name, or label of column * @param text Text to be found in the specified column * @param checked true for checked, false for unchecked * @return this grid */ - public ResponsiveGrid selectRow(String fieldLabel, String text, boolean checked) + public ResponsiveGrid selectRow(CharSequence columnIdentifier, String text, boolean checked) { - GridRow row = getRow(fieldLabel, text); + GridRow row = getRow(columnIdentifier, text); selectRowAndVerifyCheckedCounts(row, checked); - getWrapper().log("Row at column ["+fieldLabel+"] with text ["+text+"] selection state set to + ["+row.isSelected()+"]"); + getWrapper().log("Row at column ["+columnIdentifier+"] with text ["+text+"] selection state set to + ["+row.isSelected()+"]"); return getThis(); } @@ -390,16 +426,16 @@ else if (!checked && row.isSelected()) /** * Sets the specified rows' selector checkboxes to the requested select state - * @param fieldLabel Header text of the column to search + * @param columnIdentifier fieldKey, name, or label of column * @param texts Text to search for in the specified column * @param checked True for checked, false for unchecked * @return this grid */ - public T selectRows(String fieldLabel, Collection texts, boolean checked) + public T selectRows(CharSequence columnIdentifier, Collection texts, boolean checked) { for (String text : texts) { - selectRow(fieldLabel, text, checked); + selectRow(columnIdentifier, text, checked); } return getThis(); } @@ -414,29 +450,6 @@ public boolean isRowSelected(int index) return new GridRow.GridRowFinder(this).index(index).find(this).isSelected(); } - /** - * finds the first row containing the specified text and returns the checked state - * @param text A value in the row, used to identify the row. (preferably a key) - * @return whether or not the selector checkbox is checked - */ - public boolean isRowSelected(String text) - { - return new GridRow.GridRowFinder(this).withCellWithText(text) - .find(this).isSelected(); - } - - /** - * finds the first row containing the specified text in the specified column and returns the checked state - * @param fieldLabel the text in the column to search - * @param text the value in the row to find - * @return true if the checkbox is checked, otherwise false - */ - public boolean isRowSelected(String fieldLabel, String text) - { - return new GridRow.GridRowFinder(this).withTextAtColumn(text, getColumnIndex(fieldLabel)) - .find(this).isSelected(); - } - protected ReactCheckBox selectAllBox() { ReactCheckBox box = elementCache().selectAllCheckbox; @@ -531,29 +544,29 @@ public Optional getOptionalRow(String text) /** * Returns the first row with matching text in the specified column - * @param fieldLabel The exact text of the column header + * @param columnIdentifier fieldKey, name, or label of column to search * @param text The full text of the cell to match * @return the first row that matches */ - public GridRow getRow(String fieldLabel, String text) + public GridRow getRow(CharSequence columnIdentifier, String text) { - return elementCache().getRow(fieldLabel, text); + return elementCache().getRow(columnIdentifier, text); } /** * Returns the first row with matching text in the specified column - * @param fieldLabel the column to search + * @param columnIdentifier fieldKey, name, or label of column of the column to search * @param text exact text to match in that column * @return the first row matching the search criteria */ - public Optional getOptionalRow(String fieldLabel, String text) + public Optional getOptionalRow(CharSequence columnIdentifier, String text) { - return elementCache().getOptionalRow(fieldLabel, text); + return elementCache().getOptionalRow(columnIdentifier, text); } /** * Returns the first row with matching text in the specified columns - * @param partialMap Map of key (column), value (text) + * @param partialMap Map of key (fieldKey, name, or label of column), value (text) * @return the first row with matching column/text for all of the supplied key/value pairs, or NotFoundException */ public GridRow getRow(Map partialMap) @@ -580,12 +593,15 @@ public List getRows() return elementCache().getRows(); } - public List getColumnDataAsText(String fieldLabel) + /** + * @param columnIdentifier fieldKey, name, or label of column + */ + public List getColumnDataAsText(CharSequence columnIdentifier) { List columnData = new ArrayList<>(); for (GridRow row : getRows()) { - columnData.add(row.getText(fieldLabel)); + columnData.add(row.getText(columnIdentifier)); } return columnData; } @@ -600,13 +616,12 @@ public boolean hasSelectColumn() } /** - * used to find the raw index of a given column as rendered in the dom. - * To get the normalized index (which excludes selector rows if present) use - * elementCache().indexes.get(column).getNormalizedIndex() + * @param columnIdentifier fieldKey, name, or label of column + * @return the DOM index of the specified column (e.g. '0' for the row selection column) */ - protected Integer getColumnIndex(String fieldLabel) + protected Integer getColumnIndex(CharSequence columnIdentifier) { - return elementCache().getColumnIndex(fieldLabel); + return elementCache().getColumnIndex(columnIdentifier); } /** @@ -644,23 +659,35 @@ public Map getRowMapByLabel(int rowIndex) /** * Get text from the specified column in the specified row + * @param columnIdentifier fieldKey, name, or label of column */ - public String getCellText(int rowIndex, String fieldLabel) + public String getCellText(int rowIndex, CharSequence columnIdentifier) { - return getRow(rowIndex).getText(fieldLabel); + return getRow(rowIndex).getText(columnIdentifier); } /** - * * @return a list of Map<String, String> containing keys and values for each row */ public List> getRowMapsByLabel() { - if(null == elementCache().mapList) - { - elementCache().mapList = elementCache()._initGridData(); - } - return elementCache().mapList; + return elementCache().getRows().stream().map(GridRow::getRowMapByLabel).toList(); + } + + /** + * @return a list of Map<String, String> containing keys and values for each row + */ + public List> getRowMapsByName() + { + return elementCache().getRows().stream().map(GridRow::getRowMapByName).toList(); + } + + /** + * @return a list of Map<String, String> containing keys and values for each row + */ + public List> getRowMapsByFieldKey() + { + return elementCache().getRows().stream().map(GridRow::getRowMapByFieldKey).toList(); } /** @@ -674,12 +701,12 @@ public void clickLink(String text) /** * locates the first link in the specified column, clicks it, and waits for the URL to update - * @param fieldLabel column in which to search + * @param columnIdentifier fieldKey, name, or label of column * @param text text for link to match */ - public void clickLink(String fieldLabel, String text) + public void clickLink(CharSequence columnIdentifier, String text) { - getRow(fieldLabel, text).clickLink(text); + getRow(columnIdentifier, text).clickLink(text); } public boolean gridMessagePresent() @@ -707,25 +734,13 @@ public String getGridError() /** The responsiveGrid now supports redacting fields * - * @param fieldLabel the column label. (uses starts-with matching) + * @param columnIdentifier fieldKey, name, or label of column * @return true if the specified grid header cell has the 'phi-protected' class on it */ - public boolean getColumnPHIProtected(String fieldLabel) - { - WebElement columnHeader = Locator.tagWithClass("th", "grid-header-cell") - .withDescendant(Locators.headerCellBody(fieldLabel)).findElement(this); - return columnHeader.getAttribute("class").contains("phi-protected"); - } - - /** - * Gets the title attribute of the column header cell, if it has one - * @param fieldLabel The text with which to find the cell (uses startswith matching) - * @return the contents of the 'title' attribute of the cell, or null if the attribute is - * not present. - */ - public String getColumnTitleAttribute(String fieldLabel) + public boolean getColumnPHIProtected(CharSequence columnIdentifier) { - return elementCache().getColumnHeaderCell(fieldLabel).getAttribute("title"); + WebElement columnHeader = elementCache().getColumnHeaderCell(columnIdentifier); + return columnHeader.getDomAttribute("class").contains("phi-protected"); } public Optional getGridEmptyMessage() @@ -749,6 +764,11 @@ public Optional getGridEmptyMessage() return msg; } + List getHeaders() + { + return Collections.unmodifiableList(elementCache().findHeaders()); + } + /** * supports chaining between base and derived instances * @return magic @@ -758,22 +778,6 @@ protected T getThis() return (T) this; } - /** - * Call this function to force a re-initialization of the internal data representation of the grid data. - * When trying to be more efficient the grid data is stored in an internal variable (so this is a stateful object). - * On creates the internal grid data is initialize by calling waitForLoaded. The waitForLoaded function is also - * called when the page/grid is navigated, but it is not when a search, or ordering is done. As a temporary work - * around this function is made public so the calling function can update the data. - * - * The real fix would be to add an event listener to the grid and reinitialize the internal data when it detects a change. - * - */ - public void initGridData() - { - waitForLoaded(); - elementCache().mapList = elementCache()._initGridData(); - } - @Override protected ElementCache newElementCache() { @@ -806,74 +810,51 @@ public void toggle() } }; - private final Map headerCells = new HashMap<>(); - protected final WebElement getColumnHeaderCell(String headerText) + protected final WebElement getColumnHeaderCell(CharSequence columnIdentifier) { - if (!headerCells.containsKey(headerText)) - { - WebElement headerCell = Locators.headerCellBody(headerText).findElement(this); - headerCells.put(headerText, headerCell); - } - return headerCells.get(headerText); + return findColumnHeader(columnIdentifier).getElement(); } - protected List fieldLabels; - protected Map indexes; - protected Map initColumnsAndIndices() + private FieldReferenceManager _fieldReferenceManager; + protected FieldReferenceManager getGridHeaderManager() { - if (fieldLabels == null || indexes == null) + if (_fieldReferenceManager == null) { + List fieldReferences = new ArrayList<>(); List headerCellElements = Locators.headerCells.findElements(this); - int offset = 0; - if (hasSelectColumn()) - { - headerCellElements.remove(0); - offset = 1; - } - fieldLabels = headerCellElements.stream().map(el -> WebElementUtils.getTextContent(el).trim()).toList(); - indexes = new HashMap<>(); - for (int i = 0; i < headerCellElements.size(); i++) + for (int domIndex = hasSelectColumn() ? 1 : 0; domIndex < headerCellElements.size(); domIndex++) { - headerCells.put(fieldLabels.get(i), headerCellElements.get(i)); // Fill out the headerCells Map since we have them all - indexes.put(fieldLabels.get(i), new ColumnIndex(fieldLabels.get(i), i+offset, i)); + fieldReferences.add(new FieldReference(headerCellElements.get(domIndex), domIndex)); } + + _fieldReferenceManager = new FieldReferenceManager(fieldReferences); } - return indexes; + return _fieldReferenceManager; } - protected int getColumnIndex(String fieldLabel) + protected List findHeaders() { - final ColumnIndex columnIndex = initColumnsAndIndices().get(fieldLabel); - if (columnIndex == null) - { - throw new NoSuchElementException(String.format("Column not found: '%s'.\nKnown columns: %s", - fieldLabel, String.join(", ", initColumnsAndIndices().keySet()))); - } - return columnIndex.getRawIndex(); + return getGridHeaderManager().getColumnHeaders(); } - protected List getColumnLabels() + protected FieldReference findColumnHeader(CharSequence columnIdentifier) { - initColumnsAndIndices(); - return fieldLabels; + return getGridHeaderManager().findFieldReference(columnIdentifier); } - protected List> mapList; - protected List gridRows; - private List> _initGridData() + protected int getColumnIndex(CharSequence columnIdentifier) { - List> rowMaps = new ArrayList<>(); - gridRows = getRows(); - for(GridRow row : gridRows) - { - rowMaps.add(row.getRowMapByLabel()); - } - return rowMaps; + return findColumnHeader(columnIdentifier).getDomIndex(); + } + + protected List getColumnLabels() + { + return findHeaders().stream().map(FieldReferenceManager.FieldReference::getLabel).collect(Collectors.toList()); } protected GridRow getRow(int index) { - return new GridRow.GridRowFinder(ResponsiveGrid.this).index(index).find(this); + return getRows().get(index); } protected GridRow getRow(String text) @@ -886,18 +867,16 @@ protected Optional getOptionalRow(String text) return new GridRow.GridRowFinder(ResponsiveGrid.this).withCellWithText(text).findOptional(this); } - protected GridRow getRow(String fieldLabel, String text) + protected GridRow getRow(CharSequence columnIdentifier, String text) { - // try to normalize column index to start at 0, excluding row selector column - Integer columnIndex = getColumnIndex(fieldLabel); + int columnIndex = getColumnIndex(columnIdentifier); return new GridRow.GridRowFinder(ResponsiveGrid.this).withTextAtColumn(text, columnIndex) .find(this); } - protected Optional getOptionalRow(String fieldLabel, String text) + protected Optional getOptionalRow(CharSequence columnIdentifier, String text) { - // try to normalize column index to start at 0, excluding row selector column - Integer columnIndex = getColumnIndex(fieldLabel); + int columnIndex = getColumnIndex(columnIdentifier); return new GridRow.GridRowFinder(ResponsiveGrid.this).withTextAtColumn(text, columnIndex) .findOptional(this); } @@ -914,9 +893,14 @@ protected GridRow getRow(Locator.XPathLocator containing) return new GridRow.GridRowFinder(ResponsiveGrid.this).withDescendant(containing).find(); } + private List gridRows; protected List getRows() { - return new GridRow.GridRowFinder(ResponsiveGrid.this).findAll(getComponentElement()); + if (gridRows == null) + { + gridRows = new GridRow.GridRowFinder(ResponsiveGrid.this).findAll(this); + } + return gridRows; } } @@ -989,36 +973,3 @@ protected Locator locator() } } } - - class ColumnIndex - { - private final Integer _rawIndex; - private final Integer _normalizedIndex; - private final String _fieldLabel; - - /** - * Helper to - * @param fieldLabel text of the column header - * @param rawIndex dom-oriented index of the column - * @param normalizedIndex index of the list of columns - */ - public ColumnIndex(String fieldLabel, int rawIndex, int normalizedIndex) - { - _fieldLabel = fieldLabel; - _rawIndex = rawIndex; - _normalizedIndex = normalizedIndex; - } - - public String getColumnLabel() - { - return _fieldLabel; - } - public Integer getRawIndex() - { - return _rawIndex; - } - public Integer getNormalizedIndex() - { - return _normalizedIndex; - } - } diff --git a/src/org/labkey/test/params/FieldDefinition.java b/src/org/labkey/test/params/FieldDefinition.java index 7c6717c9ee..79ba2ea9b1 100644 --- a/src/org/labkey/test/params/FieldDefinition.java +++ b/src/org/labkey/test/params/FieldDefinition.java @@ -22,6 +22,7 @@ import org.json.JSONObject; import org.junit.Assert; import org.labkey.api.exp.query.ExpSchema; +import org.labkey.remoteapi.domain.ConditionalFormat; import org.labkey.remoteapi.domain.PropertyDescriptor; import org.labkey.remoteapi.query.Filter; import org.labkey.test.components.html.OptionSelect; @@ -216,6 +217,13 @@ public FieldDefinition setHidden(Boolean hidden) return this; } + @Override + public FieldDefinition setConditionalFormats(List conditionalFormats) + { + super.setConditionalFormats(conditionalFormats); + return this; + } + public LookupInfo getLookup() { return _type.getLookupInfo(); diff --git a/src/org/labkey/test/params/FieldInfo.java b/src/org/labkey/test/params/FieldInfo.java new file mode 100644 index 0000000000..ebd28a0019 --- /dev/null +++ b/src/org/labkey/test/params/FieldInfo.java @@ -0,0 +1,110 @@ +package org.labkey.test.params; + +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Immutable alternative to 'FieldDefinition' + * Use this for shared global field information + */ +public class FieldInfo +{ + private final FieldKey _fieldKey; + private final String _label; + private final FieldDefinition.ColumnType _columnType; + private final Consumer _fieldDefinitionMutator; + + private FieldInfo(FieldKey fieldKey, String label, FieldDefinition.ColumnType columnType, Consumer fieldDefinitionMutator) + { + _fieldKey = fieldKey; + _label = label; + _columnType = Objects.requireNonNullElse(columnType, FieldDefinition.ColumnType.String); + _fieldDefinitionMutator = fieldDefinitionMutator; + } + + public FieldInfo(String name, String label, FieldDefinition.ColumnType columnType) + { + this(FieldKey.fromParts(name.trim()), label, columnType, null); + } + + public FieldInfo(String name, String label) + { + this(name, label, null); + } + + public FieldInfo(String name, FieldDefinition.ColumnType columnType) + { + this(name, null, columnType); + } + + public FieldInfo(String name) + { + this(name, null, null); + } + + public FieldInfo customizeFieldDefinition(Consumer fieldDefinitionMutator) + { + return new FieldInfo(_fieldKey, _label, _columnType, fieldDefinitionMutator); + } + + protected String getRawLabel() + { + return _label; + } + + public String getLabel() + { + return Objects.requireNonNullElseGet(getRawLabel(), () -> FieldDefinition.labelFromName(_fieldKey.getName())); + } + + public FieldKey getFieldKey() + { + return _fieldKey; + } + + public String getName() + { + return _fieldKey.getName(); + } + + public FieldKey child(String name) + { + return _fieldKey.child(name); + } + + public FieldDefinition getFieldDefinition() + { + return getFieldDefinition(_columnType); + } + + public FieldDefinition getFieldDefinition(String lookupContainerPath) + { + if (!_columnType.isLookup()) + { + throw new IllegalArgumentException("Unable to set lookup container for %s column: %s".formatted(_columnType.getLabel(), getName())); + } + else + { + String schema = _columnType.getLookupInfo().getSchema(); + String table = _columnType.getLookupInfo().getTable(); + FieldDefinition.ColumnType columnType = _columnType.getRangeURI().equals(FieldDefinition.ColumnType.Integer.getRangeURI()) + ? new FieldDefinition.IntLookup(lookupContainerPath, schema, table) + : new FieldDefinition.StringLookup(lookupContainerPath, schema, table); + return getFieldDefinition(columnType); + } + } + + private FieldDefinition getFieldDefinition(FieldDefinition.ColumnType columnType) + { + FieldDefinition fieldDefinition = new FieldDefinition(getName(), columnType); + if (getRawLabel() != null) + { + fieldDefinition.setLabel(getRawLabel()); + } + if (_fieldDefinitionMutator != null) + { + _fieldDefinitionMutator.accept(fieldDefinition); + } + return fieldDefinition; + } +} diff --git a/src/org/labkey/test/params/FieldKey.java b/src/org/labkey/test/params/FieldKey.java index 1a666faf8d..6070cbde5f 100644 --- a/src/org/labkey/test/params/FieldKey.java +++ b/src/org/labkey/test/params/FieldKey.java @@ -1,56 +1,188 @@ package org.labkey.test.params; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; import java.util.List; -import java.util.stream.Collectors; -public class FieldKey +public class FieldKey implements CharSequence { - private final List _parts; - private String _label; + public static final FieldKey EMPTY = new FieldKey(""); // Useful as a sort of FieldKey builder starting point + public static final FieldKey SOURCES_FK = new FieldKey("DataInputs"); + public static final FieldKey PARENTS_FK = new FieldKey("MaterialInputs"); + + private static final String SEPARATOR = "/"; + + private final FieldKey _parent; + private final String _name; + private final String _fieldKey; + + private FieldKey(String name) + { + _parent = null; + _name = name; + _fieldKey = encodePart(name); + } + + private FieldKey(FieldKey parent, String child) + { + _parent = parent; + _name = parent.getName() + SEPARATOR + child; + _fieldKey = parent + SEPARATOR + encodePart(child); + } - private FieldKey(String... parts) + public static FieldKey fromParts(List parts) { - if (parts.length == 0) + FieldKey fieldKey = EMPTY; + + for (String part : parts) { - throw new IllegalArgumentException("No field key parts were provided."); + if (StringUtils.isBlank(part)) + throw new IllegalArgumentException("FieldKey contains a blank part: " + parts); + fieldKey = fieldKey.child(part); } - // '/' is used as a separator character in fieldKeys. Slashes in field names are encoded as '$S' - _parts = Arrays.stream(parts) - .map(part -> part.replace("/", "$S")) - .collect(Collectors.toList()); - _label = parts[parts.length - 1]; + + return fieldKey; } public static FieldKey fromParts(String... parts) { - return new FieldKey(parts); + return fromParts(Arrays.asList(parts)); } - public static FieldKey fromPath(String path) + /** + * Construct a FieldKey from a CharSequence that might be an encoded fieldKey + * @param fieldKey String or FieldKey + * @return FieldKey representation of the String, or the identity if a FieldKey was provided + */ + public static @Nullable FieldKey fromFieldKey(CharSequence fieldKey) { - return new FieldKey(path.split("/")); + if (fieldKey instanceof FieldKey fk) + { + return fk; + } + else + { + try + { + return fromParts(Arrays.stream(fieldKey.toString().split(SEPARATOR)).map(FieldKey::decodePart).toList()); + } + catch (IllegalArgumentException iae) + { + return null; + } + } } - public String getLabel() + /** + * Construct a FieldKey from a CharSequence that might be a field name + * @param nameOrFieldKey unencoded field name or an existing FieldKey object + * @return fieldKey encoded name, or the identity if one was provided + */ + public static FieldKey fromName(CharSequence nameOrFieldKey) { - return _label; + if (nameOrFieldKey instanceof FieldKey fk) + return fk; + else + return fromParts(nameOrFieldKey.toString()); } - public FieldKey setLabel(String label) + private static final String[] ILLEGAL = {"$", "/", "&", "}", "~", ",", "."}; + private static final String[] REPLACEMENT = {"$D", "$S", "$A", "$B", "$T", "$C", "$P"}; + + public static String encodePart(String str) { - _label = label; - return this; + return StringUtils.replaceEach(str, ILLEGAL, REPLACEMENT); } - public List getParts() + public static String decodePart(String str) { - return _parts; + return StringUtils.replaceEach(str, REPLACEMENT, ILLEGAL); + } + + public FieldKey getParent() + { + return _parent; + } + + public FieldKey child(String name) + { + if (StringUtils.isBlank(getName())) + { + return new FieldKey(name); + } + else + { + return new FieldKey(this, name); + } + } + + public Iterator getIterator() + { + List ancestors = new ArrayList<>(); + FieldKey temp = this; + + while (temp != null) + { + ancestors.add(temp); + temp = temp.getParent(); + } + + Collections.reverse(ancestors); + + return ancestors.iterator(); + } + + public String getName() + { + return _name; + } + + public String[] getNameArray() + { + return Arrays.stream(_fieldKey.split(SEPARATOR)).map(FieldKey::decodePart).toArray(String[]::new); + } + + @Override + public @NotNull String toString() + { + return _fieldKey; + } + + @Override + public int length() + { + return _fieldKey.length(); + } + + @Override + public char charAt(int index) + { + return _fieldKey.charAt(index); + } + + @Override + public @NotNull CharSequence subSequence(int start, int end) + { + return _fieldKey.subSequence(start, end); + } + + @Override + public final boolean equals(Object o) + { + if (!(o instanceof FieldKey fieldKey)) return false; + + return _fieldKey.equalsIgnoreCase(fieldKey._fieldKey); // FieldKeys aren't case-sensitive? } @Override - public String toString() + public int hashCode() { - return String.join("/", getParts()); + return _fieldKey.toLowerCase().hashCode(); // FieldKeys aren't case-sensitive? } } diff --git a/src/org/labkey/test/params/list/IntListDefinition.java b/src/org/labkey/test/params/list/IntListDefinition.java index c9f30ffdd6..4c755e4ffe 100644 --- a/src/org/labkey/test/params/list/IntListDefinition.java +++ b/src/org/labkey/test/params/list/IntListDefinition.java @@ -6,6 +6,8 @@ import org.labkey.test.util.TestDataGenerator; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; public class IntListDefinition extends ListDefinition { @@ -53,6 +55,21 @@ protected String getKeyType() @Override public TestDataGenerator getTestDataGenerator(String containerPath) { - return super.getTestDataGenerator(containerPath).setAutoGeneratedFields(getKeyName()); + if (isAutoIncrementKey) + { + return super.getTestDataGenerator(containerPath).setAutoGeneratedFields(getKeyName()); + } + else + { + // supply key values but ensure they don't collide + return super.getTestDataGenerator(containerPath).addDataSupplier(getKeyName(), new Supplier<>() { + private final AtomicInteger keys = new AtomicInteger(1); + @Override + public Object get() + { + return keys.getAndIncrement(); + } + }); + } } } diff --git a/src/org/labkey/test/stress/RequestInfoTsvWriter.java b/src/org/labkey/test/stress/RequestInfoTsvWriter.java index 4318e6c8c3..77d8a0231c 100644 --- a/src/org/labkey/test/stress/RequestInfoTsvWriter.java +++ b/src/org/labkey/test/stress/RequestInfoTsvWriter.java @@ -2,7 +2,7 @@ import org.labkey.remoteapi.miniprofiler.RequestInfo; import org.labkey.serverapi.writer.PrintWriters; -import org.labkey.test.util.TestDataUtils; +import org.labkey.test.util.data.TestDataUtils; import java.io.File; import java.io.FileNotFoundException; diff --git a/src/org/labkey/test/tests/ClientAPITest.java b/src/org/labkey/test/tests/ClientAPITest.java index 6d2c451df2..2d43a013b1 100644 --- a/src/org/labkey/test/tests/ClientAPITest.java +++ b/src/org/labkey/test/tests/ClientAPITest.java @@ -57,7 +57,7 @@ import org.labkey.test.util.PermissionsHelper; import org.labkey.test.util.PortalHelper; import org.labkey.test.util.StudyHelper; -import org.labkey.test.util.TestDataUtils; +import org.labkey.test.util.data.TestDataUtils; import org.labkey.test.util.UIUserHelper; import org.labkey.test.util.WikiHelper; import org.labkey.test.util.query.QueryUtils; @@ -964,7 +964,7 @@ public void webpartTest() assertTextPresent("Webpart Title"); for (FieldDefinition column : LIST_COLUMNS) - assertTextPresent(column.getLabel()); + assertTextPresent(column.getEffectiveLabel()); } @Test diff --git a/src/org/labkey/test/tests/FilterTest.java b/src/org/labkey/test/tests/FilterTest.java index 7d24166cc9..561d5c3a1a 100644 --- a/src/org/labkey/test/tests/FilterTest.java +++ b/src/org/labkey/test/tests/FilterTest.java @@ -586,7 +586,7 @@ private void validFilterGeneratesCorrectResultsTest(FieldDefinition columnDef, S DataRegionTable region = new DataRegionTable(TABLE_NAME, this); region.setFilter(fieldKey, filter1Type, filter1, filter2Type, filter2); - checkFilterWasApplied(textPresentAfterFilter, textNotPresentAfterFilter, columnDef.getLabel(), filter1Type, filter1, filter2Type, filter2); + checkFilterWasApplied(textPresentAfterFilter, textNotPresentAfterFilter, columnDef.getEffectiveLabel(), filter1Type, filter1, filter2Type, filter2); log("** Checking filter present in R view"); region.goToReport(R_VIEW); diff --git a/src/org/labkey/test/tests/SampleTypeNameExpressionTest.java b/src/org/labkey/test/tests/SampleTypeNameExpressionTest.java index 39dac35804..ef82650e95 100644 --- a/src/org/labkey/test/tests/SampleTypeNameExpressionTest.java +++ b/src/org/labkey/test/tests/SampleTypeNameExpressionTest.java @@ -61,7 +61,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.labkey.test.params.FieldDefinition.DOMAIN_TRICKY_CHARACTERS; -import static org.labkey.test.util.TestDataUtils.getEscapedNameExpression; +import static org.labkey.test.util.data.TestDataUtils.getEscapedNameExpression; @Category({Daily.class}) @BaseWebDriverTest.ClassTimeout(minutes = 5) diff --git a/src/org/labkey/test/tests/SampleTypeRemoteAPITest.java b/src/org/labkey/test/tests/SampleTypeRemoteAPITest.java index 552bf0e267..1d165115fe 100644 --- a/src/org/labkey/test/tests/SampleTypeRemoteAPITest.java +++ b/src/org/labkey/test/tests/SampleTypeRemoteAPITest.java @@ -173,7 +173,7 @@ public void importMissingValueSampleType() throws IOException, CommandException dgen.addCustomRow(Map.of("name", "Eighth","mvStringData", "ActualData", "volume", 17.5)); // write the domain data into TSV format, for import via the UI - String importTsv = dgen.writeTsvContents(); + String importTsv = dgen.getDataAsTsv(); refresh(); DataRegionTable sampleTypeList = DataRegionTable.DataRegion(getDriver()).withName(SAMPLE_TYPE_DATA_REGION_NAME).waitFor(); @@ -365,7 +365,7 @@ public void exportMissingValueSampleTypeToTSV() throws CommandException, IOExcep dgen.insertRows(createDefaultConnection(), dgen.getRows()); // insert data via API rather than UI // prepare expected values- - String expectedTSVData = dgen.writeTsvContents(); + String expectedTSVData = dgen.getDataAsTsv(); String[] tsvRows = expectedTSVData.split("\n"); List dataRows = new ArrayList(); for (int i=1; i < tsvRows.length; i++) // don't validate columns; we expect labels instead of column names @@ -775,7 +775,7 @@ private void insertAssayData(String assayName, List dataGener { AssayImportPage page = new AssayImportPage(getDriver()) .setNamedTextAreaValue("TextAreaDataCollector.textArea", - dataGen.writeTsvContents()); + dataGen.getDataAsTsv()); imported++; if(imported < limit) diff --git a/src/org/labkey/test/tests/assay/UploadLargeExcelAssayTest.java b/src/org/labkey/test/tests/assay/UploadLargeExcelAssayTest.java index c813094936..167abd2662 100644 --- a/src/org/labkey/test/tests/assay/UploadLargeExcelAssayTest.java +++ b/src/org/labkey/test/tests/assay/UploadLargeExcelAssayTest.java @@ -7,39 +7,28 @@ import org.labkey.remoteapi.domain.PropertyDescriptor; import org.labkey.test.BaseWebDriverTest; import org.labkey.test.Locator; -import org.labkey.test.TestFileUtils; import org.labkey.test.WebDriverWrapper; import org.labkey.test.categories.Assays; import org.labkey.test.categories.Daily; -import org.labkey.test.pages.ImportDataPage; import org.labkey.test.pages.ReactAssayDesignerPage; -import org.labkey.test.pages.assay.AssayImportPage; -import org.labkey.test.pages.assay.AssayRunsPage; import org.labkey.test.pages.assay.AssayUploadJobsPage; import org.labkey.test.pages.query.SourceQueryPage; import org.labkey.test.params.FieldDefinition; import org.labkey.test.params.assay.GeneralAssayDesign; -import org.labkey.test.params.experiment.SampleTypeDefinition; import org.labkey.test.util.AbstractDataRegionExportOrSignHelper; -import org.labkey.test.util.DataRegionTable; -import org.labkey.test.util.SampleTypeHelper; import org.labkey.test.util.TestDataGenerator; -import org.labkey.test.util.TestLogger; -import org.labkey.test.util.exp.SampleTypeAPIHelper; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import static org.junit.Assert.*; - @Category({Assays.class, Daily.class}) public class UploadLargeExcelAssayTest extends BaseWebDriverTest { public static String LARGE_ASSAY = "chaos_assay"; public static String LARGE_ASSAY_2 = "large_assay_2"; - public static List ASSAY_FIELDS = new ArrayList(); + public static List ASSAY_FIELDS = new ArrayList<>(); @Override protected void doCleanup(boolean afterTest) @@ -96,8 +85,9 @@ public void testUpload200kRows() throws Exception String fileName = "200kXlsxFile.xlsx"; var dgen = new TestDataGenerator("samples", "chaos_sample", getProjectName()) .withColumns(ASSAY_FIELDS); + dgen.generateRows(200_000); log("writing large .xlsx file"); - var largeExcelFile = dgen.writeGeneratedDataToExcel(200000, "chaos", fileName); + var largeExcelFile = dgen.writeData(fileName); log("finished writing large .xlsx file"); // import large generated excel to assay1 diff --git a/src/org/labkey/test/tests/component/EditableGridTest.java b/src/org/labkey/test/tests/component/EditableGridTest.java index 122b4e0d47..c2d4804ead 100644 --- a/src/org/labkey/test/tests/component/EditableGridTest.java +++ b/src/org/labkey/test/tests/component/EditableGridTest.java @@ -1294,7 +1294,7 @@ private void checkSelectedStyle(EditableGrid editableGrid, private String getActualPaste(EditableGrid testGrid) { - List> gridData = testGrid.getGridData(PASTE_1, PASTE_2, PASTE_3, PASTE_4, PASTE_5); + List> gridData = testGrid.getGridDataByLabel(PASTE_1, PASTE_2, PASTE_3, PASTE_4, PASTE_5); List> rows = gridData.stream().map(r -> List.of(r.get(PASTE_1), r.get(PASTE_2), r.get(PASTE_3), r.get(PASTE_4), r.get(PASTE_5))).toList(); return rowsToString(rows); } diff --git a/src/org/labkey/test/tests/component/GridPanelViewTest.java b/src/org/labkey/test/tests/component/GridPanelViewTest.java index a3992c8ca3..c7452ef834 100644 --- a/src/org/labkey/test/tests/component/GridPanelViewTest.java +++ b/src/org/labkey/test/tests/component/GridPanelViewTest.java @@ -19,6 +19,7 @@ import org.labkey.test.components.ui.search.FilterExpressionPanel; import org.labkey.test.components.ui.search.FilterFacetedPanel; import org.labkey.test.params.FieldDefinition; +import org.labkey.test.params.FieldKey; import org.labkey.test.params.experiment.SampleTypeDefinition; import org.labkey.test.util.APIUserHelper; import org.labkey.test.util.ApiPermissionsHelper; @@ -1022,7 +1023,7 @@ public void testShowAllLabelEditAndUndo() log(String.format("Change the label of the field '%s' to '%s'.", materialNameField, newFieldLabel)); // Adding the 'Material Source Id / Name' field creates two fields with the label 'Name' in the 'Shown in Grid' panel, make sure the expected one is updated. - customizeModal.setFieldLabel(materialNameField, 1, newFieldLabel); + customizeModal.setFieldLabel(FieldKey.fromParts(materialIDField, materialNameField), newFieldLabel); checker().fatal().verifyTrue("'Update' button is not enabled, cannot save changes. Fatal error.", customizeModal.isUpdateGridEnabled()); diff --git a/src/org/labkey/test/util/EscapeUtil.java b/src/org/labkey/test/util/EscapeUtil.java index 82a67be918..652fef926f 100644 --- a/src/org/labkey/test/util/EscapeUtil.java +++ b/src/org/labkey/test/util/EscapeUtil.java @@ -15,9 +15,9 @@ */ package org.labkey.test.util; -import org.apache.commons.lang3.StringUtils; import org.apache.commons.text.StringEscapeUtils; import org.eclipse.jetty.util.URIUtil; +import org.labkey.test.params.FieldKey; import java.net.URLDecoder; import java.net.URLEncoder; @@ -119,17 +119,14 @@ public static String decodeUriPath(String path) return URIUtil.decodePath(path); } - private static final String[] ILLEGAL = {"$", "/", "&", "}", "~", ",", "."}; - private static final String[] REPLACEMENT = {"$D", "$S", "$A", "$B", "$T", "$C", "$P"}; - - static public String fieldKeyEncodePart(String str) + public static String fieldKeyEncodePart(String str) { - return StringUtils.replaceEach(str, ILLEGAL, REPLACEMENT); + return FieldKey.encodePart(str); } - static public String fieldKeyDecodePart(String str) + public static String fieldKeyDecodePart(String str) { - return StringUtils.replaceEach(str, REPLACEMENT, ILLEGAL); + return FieldKey.decodePart(str); } public static String getTextChoiceValidatorExpression(List options) diff --git a/src/org/labkey/test/util/TestDataGenerator.java b/src/org/labkey/test/util/TestDataGenerator.java index 86da97976d..5ca901f3f0 100644 --- a/src/org/labkey/test/util/TestDataGenerator.java +++ b/src/org/labkey/test/util/TestDataGenerator.java @@ -15,6 +15,7 @@ */ package org.labkey.test.util; +import org.apache.commons.csv.CSVFormat; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.time.DateUtils; @@ -36,16 +37,16 @@ import org.labkey.remoteapi.query.SelectRowsResponse; import org.labkey.remoteapi.query.Sort; import org.labkey.serverapi.reader.TabLoader; -import org.labkey.serverapi.writer.PrintWriters; import org.labkey.test.TestFileUtils; import org.labkey.test.WebTestHelper; import org.labkey.test.params.FieldDefinition; +import org.labkey.test.util.data.ColumnNameMapper; +import org.labkey.test.util.data.TestDataUtils; import org.labkey.test.util.query.QueryApiHelper; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; -import java.io.PrintWriter; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; @@ -59,12 +60,11 @@ import java.util.concurrent.ThreadLocalRandom; import java.util.function.Supplier; import java.util.regex.Pattern; -import java.util.stream.Collectors; import static org.labkey.test.BaseWebDriverTest.ALL_ILLEGAL_QUERY_KEY_CHARACTERS; -import static org.labkey.test.util.TestDataUtils.REALISTIC_ASSAY_FIELDS; -import static org.labkey.test.util.TestDataUtils.REALISTIC_SAMPLE_FIELDS; -import static org.labkey.test.util.TestDataUtils.REALISTIC_SOURCE_FIELDS; +import static org.labkey.test.util.data.TestDataUtils.REALISTIC_ASSAY_FIELDS; +import static org.labkey.test.util.data.TestDataUtils.REALISTIC_SAMPLE_FIELDS; +import static org.labkey.test.util.data.TestDataUtils.REALISTIC_SOURCE_FIELDS; /** @@ -72,8 +72,11 @@ */ public class TestDataGenerator { + private static final String WIDE_CHAR = "👾"; + private static final char WIDE_PLACEHOLDER = 'Π'; // Wide character can't be picked from the string with 'charAt' + private static final String NON_LATIN_STRING = "и안は"; // chose a Character random from this String - public static final String CHARSET_STRING = "ABCDEFG01234abcdefvxyz~!@#$%^&*()-+=_{}[]|:;\"',.<>"; + public static final String CHARSET_STRING = "ABCDEFG01234abcdefvxyz~!@#$%^&*()-+=_{}[]|:;\"',.<>" + NON_LATIN_STRING + WIDE_PLACEHOLDER; public static final String ALPHANUMERIC_STRING = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvxyz"; public static final String DOMAIN_SPECIAL_STRING = "+- _.:&()/"; public static final String ILLEGAL_DOMAIN_NAME_CHARSET = "<>[]{};,`\"~!@#$%^*=|?\\"; @@ -98,7 +101,6 @@ public class TestDataGenerator private final String _containerPath; private String _excludedChars; private boolean _alphaNumericStr; - private final TestDataUtils.TsvQuoter _tsvQuoter = new TestDataUtils.TsvQuoter(','); /** * use TestDataGenerator to generate data to a specific fieldSet @@ -121,24 +123,10 @@ public TestDataGenerator(FieldDefinition.LookupInfo lookupInfo) public static File writeCsvFile(List fields, List> entityData, String fileName) throws IOException { - List> fileData = new ArrayList<>(); - List fieldNamesForFile = fields.stream().map(FieldDefinition::getName).collect(Collectors.toList()); - for (Map row : entityData) - { - Map fileRow = new HashMap<>(); - for (FieldDefinition field : fields) - { - String key = field.getLabel() == null ? field.getName() : field.getLabel(); - Object value = row.get(key); - Object valueToWrite = value; - if (value instanceof List) - valueToWrite = StringUtils.join((List)value, ","); - fileRow.put(field.getName(), valueToWrite); - } - fileData.add(fileRow); - } - var fileContents = TestDataUtils.csvStringFromRowMaps(fileData, fieldNamesForFile, true); - return TestFileUtils.writeTempFile(fileName, fileContents); + List> rows = TestDataUtils.replaceColumnHeaders( + TestDataUtils.rowListsFromMaps(entityData), ColumnNameMapper.labelToName(fields)); // Use field names + + return TestDataUtils.writeRowsToCsv(fileName, rows); } public static List> generateEntityData(List fields, String nameField, String namePrefix, int startingCount, int size, boolean addLineage, boolean includeAliquots, String queryName, boolean forGridInsert) @@ -162,7 +150,7 @@ public static List> generateEntityData(List { if (!fieldDefinition.getName().equalsIgnoreCase(nameField)) // Name already set { - String key = fieldDefinition.getLabel() != null ? fieldDefinition.getLabel() : FieldDefinition.labelFromName(fieldDefinition.getName()); + String key = fieldDefinition.getEffectiveLabel(); if (fieldDefinition.getType().equals(FieldDefinition.ColumnType.Date)) entityData.put(key, UI_DATE_FORMAT.get().format(TestDateUtils.diffFromTodaysDate(Calendar.HOUR, i * 24))); else if (fieldDefinition.getType().equals(FieldDefinition.ColumnType.DateAndTime)) @@ -265,14 +253,6 @@ else if (i % 5 == 3) return fields; } - public static void setFieldCaptionsFromNames(List fields) - { - fields.forEach(f -> { - if (f.getLabel() == null) - f.setLabel(FieldDefinition.labelFromName(f.getName())); - }); - } - public String getSchema() { return _schemaName; @@ -468,6 +448,12 @@ private Supplier getDefaultDataSupplier(String columnType) case "date": case "datetime": return ()-> randomDateString(DateUtils.addWeeks(new Date(), -39), new Date()); + case "time": + return ()-> + randomInt(0, 23) + ":" + // hour + StringUtils.leftPad(String.valueOf(randomInt(0, 59)), 2, "0") + ":" + // minute + StringUtils.leftPad(String.valueOf(randomInt(0, 59)), 2, "0") + "." + // second + StringUtils.leftPad(String.valueOf(randomInt(0, 999)), 3, "0"); // millisecond default: throw new IllegalArgumentException("ColumnType " + columnType + " isn't implemented yet"); } @@ -493,7 +479,11 @@ public static String randomString(int size, @Nullable String exclusion, @Nullabl for (int i=0; i?!@#^"; + String chars = ALL_ILLEGAL_QUERY_KEY_CHARACTERS + " %()=+-[]_|*`'\":;<>?!@#^" + NON_LATIN_STRING + WIDE_PLACEHOLDER ; String randomFieldName = randomName(part, numStartChars, numEndChars, chars, exclusion); TestLogger.log("Generated random field name: " + randomFieldName); @@ -648,111 +638,39 @@ public boolean randomBoolean() return ThreadLocalRandom.current().nextBoolean(); } - public StringBuilder writeTsvHeaders() + private @NotNull List getFieldsForFile() { - StringBuilder builder = new StringBuilder(); List fieldNames = new ArrayList<>(_columns.keySet()); fieldNames.removeAll(_autoGeneratedFields); - - List headers = new ArrayList<>(); - for (String fieldName : fieldNames) - headers.add(getTsvQuotedValue(fieldName, _tsvQuoter)); - - builder.append(String.join("\t", headers)); - builder.append("\n"); - - return builder; - } - - public static String getTsvQuotedValue(Object value, TestDataUtils.TsvQuoter tsvQuoter) - { - String strVal; - if (value instanceof String s) - strVal = tsvQuoter.quoteValue(s); - else - strVal = value != null ? String.valueOf(value) : ""; - - return strVal; + return fieldNames; } + /** + * Get generated data for the specified column. + * Values will be quoted appropriately for pasting into editable grid lookups. + */ public List getPasteColumnValues(String fieldName) { List values = new ArrayList<>(); for (Map row : _rows) { Object value = row.get(fieldName); - String strVal = getTsvQuotedValue(value, _tsvQuoter); + String strVal = CSVFormat.DEFAULT.format(value); // Just quote commas values.add(strVal); } return values; } - public String rowToString(List fieldNames, Map row) - { - StringBuilder builder = new StringBuilder(); - List values = new ArrayList<>(); - String firstField = fieldNames.get(0); - for (String fieldName : fieldNames) - { - Object value = row.get(fieldName); - String strVal = getTsvQuotedValue(value, _tsvQuoter); - - if (strVal.startsWith("#") && fieldName.equals(firstField)) // don't generate comment lines - strVal = "\"" + strVal + "\""; - - values.add(strVal); - } - builder.append(String.join("\t", values)); - builder.append("\n"); - - return builder.toString(); - } - /** * generates tsv-formatted content using the rows in the current instance; - * @return TSV formatted representation of generated rows + * @return TSV formatted representation of generated data */ - public String writeTsvContents() + public String getDataAsTsv() { - return writeTsvContents(true); + return TestDataUtils.stringFromRowMaps(_rows, getFieldsForFile(), true, CSVFormat.TDF); } - public String writeTsvContents(boolean includeHeaders) - { - List fieldNames = new ArrayList<>(_columns.keySet()); - fieldNames.removeAll(_autoGeneratedFields); - StringBuilder builder = includeHeaders ? writeTsvHeaders() : new StringBuilder(); - - for (Map row : _rows) - { - builder.append(rowToString(fieldNames, row)); - } - return builder.toString(); - } - - public File writeGeneratedDataToFile(int numberOfRowsToGenerate, String fileName) throws IOException - { - String headers = writeTsvHeaders().toString(); - File file = new File(TestFileUtils.getTestTempDir(), fileName); - FileUtils.forceMkdirParent(file); - - try (PrintWriter stream = PrintWriters.getPrintWriter(file)) - { - List fieldNames = new ArrayList<>(_columns.keySet()); - fieldNames.removeAll(_autoGeneratedFields); - stream.write(headers); - - for (int i = 0; i < numberOfRowsToGenerate; i++) - { - Map newRow = generateRow(); - var rowMapString = rowToString(fieldNames, newRow); - stream.write(rowMapString); - } - } - return file; - } - - public File writeGeneratedDataToExcel(int numberOfRowsToGenerate, String sheetName, String fileName) throws IOException + public File writeGeneratedDataToExcel(String sheetName, String fileName) throws IOException { File file = new File(TestFileUtils.getTestTempDir(), fileName); FileUtils.forceMkdirParent(file); @@ -763,21 +681,21 @@ public File writeGeneratedDataToExcel(int numberOfRowsToGenerate, String sheetNa var sheet = workbook.createSheet(sheetName); // write headers as row 0 - String[] columnNames = _columns.keySet().toArray(new String[0]); + List columnNames = getFieldsForFile(); var headerRow = sheet.createRow(0); - for (int i = 0; i < columnNames.length; i++) + for (int i = 0; i < columnNames.size(); i++) { - headerRow.createCell(i).setCellValue(columnNames[i]); + headerRow.createCell(i).setCellValue(columnNames.get(i)); } // write content - for (int i = 1; i < numberOfRowsToGenerate +1; i++) + for (int i = 0; i < _rows.size(); i++) { - Map row = generateRow(); - SXSSFRow currentRow = sheet.createRow(i); - for (int j = 0; j < columnNames.length; j++) + Map row = _rows.get(i); + SXSSFRow currentRow = sheet.createRow(i + 1); + for (int j = 0; j < columnNames.size(); j++) { - currentRow.createCell(j).setCellValue(row.get(columnNames[j]).toString()); + currentRow.createCell(j).setCellValue(row.getOrDefault(columnNames.get(j), "").toString()); } } workbook.write(out); @@ -787,21 +705,44 @@ public File writeGeneratedDataToExcel(int numberOfRowsToGenerate, String sheetNa } /** - * Creates a file containing the contents of the current rows, formatted in TSV. + * Creates a file containing the contents of the current rows, formatted in TSV, CSV, or xlsx. * The file is written to the test temp dir * @param fileName the name of the file, e.g. 'testDataFileForMyTest.tsv' - * @return File object pointing at created TSV + * @return File object pointing at created file */ public File writeData(String fileName) + { + String fileExtension = fileName.toLowerCase().substring(fileName.lastIndexOf('.') + 1); + switch (fileExtension) + { + case "xlsx": + case "xls": + try + { + return writeGeneratedDataToExcel("sheet1", fileName); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + case "csv": + return writeData(fileName, CSVFormat.DEFAULT); + case "tsv": + return writeData(fileName, CSVFormat.TDF); + default: + throw new IllegalArgumentException("Unsupported file extension: " + fileExtension); + } + } + + public File writeData(String fileName, CSVFormat format) { try { - return TestFileUtils.writeTempFile(fileName, writeTsvContents()); + return TestDataUtils.writeRowsToFile(fileName, TestDataUtils.rowListsFromMaps(_rows, getFieldsForFile()), format); } catch (IOException e) { - e.printStackTrace(); - return null; + throw new RuntimeException(e); } } @@ -869,13 +810,12 @@ public static List shuffleSelect(List allFields, int selectCount) return shuffled.subList(0, selectCount); } - // Require suppliers to provide reassurance that mutable objects are only reused intentionally - public static List randomSelect(List> allFields, int selectCount) + public static List randomSelect(List allOptions, int selectCount) { List selected = new ArrayList<>(); for (int i = 0; i < selectCount; i++) { - selected.add(allFields.get(randomInt(0, allFields.size())).get()); + selected.add(allOptions.get(randomInt(0, allOptions.size()))); } return selected; } diff --git a/src/org/labkey/test/util/data/ColumnNameMapper.java b/src/org/labkey/test/util/data/ColumnNameMapper.java new file mode 100644 index 0000000000..e8160139e3 --- /dev/null +++ b/src/org/labkey/test/util/data/ColumnNameMapper.java @@ -0,0 +1,61 @@ +package org.labkey.test.util.data; + +import org.labkey.test.params.FieldDefinition; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +public class ColumnNameMapper implements Function +{ + private final Function _nameMapper; + private final Map _mappingOverrides = new HashMap<>(); + + public ColumnNameMapper(Function nameMapper) + { + _nameMapper = nameMapper; + } + + public ColumnNameMapper(Map mappings) + { + this(Function.identity()); + _mappingOverrides.putAll(mappings); + } + + public static ColumnNameMapper labelToName(Collection fields) + { + return new ColumnNameMapper(label -> fields.stream() + .collect(Collectors.toMap(FieldDefinition::getEffectiveLabel, FieldDefinition::getName)) + .getOrDefault(label, label)); + } + + public static ColumnNameMapper nameToLabel(Collection fields) + { + Map map = fields.stream().collect(Collectors.toMap(FieldDefinition::getName, FieldDefinition::getEffectiveLabel)); + return new ColumnNameMapper(name -> Objects.requireNonNullElseGet(map.get(name), () -> FieldDefinition.labelFromName(name))); + } + + public ColumnNameMapper addMapping(String from, String to) + { + _mappingOverrides.put(from, to); + return this; + } + + @Override + public String apply(String s) + { + List> options = List.of( + () -> _mappingOverrides.get(s), + () -> _nameMapper.apply(s)); + return options.stream() + .map(Supplier::get) + .filter(Objects::nonNull) + .findFirst() + .orElse(s); + } +} diff --git a/src/org/labkey/test/util/TestDataUtils.java b/src/org/labkey/test/util/data/TestDataUtils.java similarity index 70% rename from src/org/labkey/test/util/TestDataUtils.java rename to src/org/labkey/test/util/data/TestDataUtils.java index 64f0b6d232..e813c1f9cf 100644 --- a/src/org/labkey/test/util/TestDataUtils.java +++ b/src/org/labkey/test/util/data/TestDataUtils.java @@ -1,24 +1,37 @@ -package org.labkey.test.util; +package org.labkey.test.util.data; import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVParser; import org.apache.commons.csv.CSVPrinter; +import org.apache.commons.csv.CSVRecord; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.labkey.serverapi.reader.DataLoader; import org.labkey.serverapi.reader.TabLoader; import org.labkey.test.TestFileUtils; import org.labkey.test.params.FieldDefinition; +import org.labkey.test.util.TestDataGenerator; import java.io.File; +import java.io.FileReader; import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; +import java.io.Reader; import java.io.StringWriter; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.function.Function; import java.util.function.Supplier; +import java.util.stream.Collectors; public class TestDataUtils { @@ -35,10 +48,8 @@ public class TestDataUtils () -> new FieldDefinition("Consumption Rate, Glucose", FieldDefinition.ColumnType.Decimal), () -> new FieldDefinition("Measurement Date/Time", FieldDefinition.ColumnType.DateAndTime), () -> new FieldDefinition("A260/A280", FieldDefinition.ColumnType.Decimal), - () -> new FieldDefinition("Nucleic Acid (ng/uL)", FieldDefinition.ColumnType.Decimal) - .setLabel("Nucleic Acid (ng/uL)"), - () -> new FieldDefinition("Concentration (by Qubit ng/uL)", FieldDefinition.ColumnType.Decimal) - .setLabel("Concentration (by Qubit ng/uL)"), + () -> new FieldDefinition("Nucleic Acid (ng/uL)", FieldDefinition.ColumnType.Decimal), + () -> new FieldDefinition("Concentration (by Qubit ng/uL)", FieldDefinition.ColumnType.Decimal), () -> new FieldDefinition("Dead (cells/ml)", FieldDefinition.ColumnType.Decimal), () -> new FieldDefinition("PDGF-AA/BB", FieldDefinition.ColumnType.Decimal), () -> new FieldDefinition("Run End Data/Time", FieldDefinition.ColumnType.DateAndTime), @@ -50,14 +61,10 @@ public class TestDataUtils () -> new FieldDefinition("FAM-Lambda..cp.Rxn."), () -> new FieldDefinition("VIC-Precision...1"), () -> new FieldDefinition("Product.Type"), - () -> new FieldDefinition("Weight.Balance_%", FieldDefinition.ColumnType.Decimal) - .setLabel("Weight.Balance %"), - () -> new FieldDefinition("Cumulative.Yield.DCW/Glucose.Consumed_g/g", FieldDefinition.ColumnType.Decimal) - .setLabel("Cumulative.Yield.DCW/Glucose.Consumed g/g"), - () -> new FieldDefinition("Average.Volume.Productivity_g/L/day", FieldDefinition.ColumnType.Decimal) - .setLabel("Average.Volume.Productivity g/L/day"), + () -> new FieldDefinition("Weight.Balance_%", FieldDefinition.ColumnType.Decimal), + () -> new FieldDefinition("Cumulative.Yield.DCW/Glucose.Consumed_g/g", FieldDefinition.ColumnType.Decimal), + () -> new FieldDefinition("Average.Volume.Productivity_g/L/day", FieldDefinition.ColumnType.Decimal), () -> new FieldDefinition("Cmol.Biomass/Cmol.Glucose.Consumed_%", FieldDefinition.ColumnType.Decimal) - .setLabel("Cmol.Biomass/Cmol.Glucose.Consumed %") ); public static final List> REALISTIC_SAMPLE_FIELDS = List.of( () -> new FieldDefinition("MW (g/mol)", FieldDefinition.ColumnType.Decimal), @@ -132,6 +139,14 @@ public static String getRealisticPlateName() return REALISTIC_PLATE_NAMES.get(TestDataGenerator.randomInt(0, REALISTIC_PLATE_NAMES.size() - 1)); } + public static List> rowMapsFromTsv(File tsvFile) throws IOException + { + try (DataLoader loader = new TabLoader.TsvFactory().createLoader(tsvFile, true)) + { + return loader.load(); + } + } + public static List> rowMapsFromTsv(String tsvString) throws IOException { try (InputStream dataStream = IOUtils.toInputStream(tsvString, StandardCharsets.UTF_8)) @@ -140,6 +155,14 @@ public static List> rowMapsFromTsv(String tsvString) throws } } + public static List> rowMapsFromCsv(File csvFile) throws IOException + { + try (DataLoader loader = new TabLoader.CsvFactory().createLoader(csvFile, true)) + { + return loader.load(); + } + } + public static List> rowMapsFromCsv(String tsvString) throws IOException { try (InputStream dataStream = IOUtils.toInputStream(tsvString, StandardCharsets.UTF_8)) @@ -148,22 +171,59 @@ public static List> rowMapsFromCsv(String tsvString) throws } } + public static String stringFromRowMaps(List> rowMaps, List columns, boolean includeHeaders, CSVFormat format) + { + return stringFromRows(rowListsFromMaps(rowMaps, columns, includeHeaders, true), format); + } + public static String tsvStringFromRowMaps(List> rowMaps, List columns, boolean includeHeaders) { - return writeRowsToString(rowListsFromMaps(rowMaps, columns, includeHeaders, true), CSVFormat.TDF); + return stringFromRowMaps(rowMaps, columns, includeHeaders, CSVFormat.TDF); } - public static String csvStringFromRowMaps(List> rowMaps, List columns, - boolean includeHeaders) + public static List> mapsFromRows(List> allRows) + { + List> rowMaps = new ArrayList<>(); + + if (allRows != null && !allRows.isEmpty()) + { + List header = allRows.get(0); + + for (int i = 1; i != allRows.size(); i++) + { + List row = allRows.get(i); + Map rowMap = new LinkedHashMap<>(); + int end = Math.min(header.size(), row.size()); + for (int col = 0; col < end; col++) + { + rowMap.put(header.get(col).toString(), row.get(col)); + } + rowMaps.add(rowMap); + } + } + + return rowMaps; + } + + public static List> dataRowsFromMaps(List> rowMaps, List columns) { - return writeRowsToString(rowListsFromMaps(rowMaps, columns, includeHeaders, true), CSVFormat.DEFAULT); + return rowListsFromMaps(rowMaps, columns, false, true); } + public static List> rowListsFromMaps(List> rowMaps) + { + Set columns = new LinkedHashSet<>(); + for (Map row : rowMaps) + { + columns.addAll(row.keySet()); + } + return rowListsFromMaps(rowMaps, new ArrayList<>(columns), true, true); + } public static List> rowListsFromMaps(List> rowMaps, List columns) { - return rowListsFromMaps(rowMaps, columns, false, true); + return rowListsFromMaps(rowMaps, columns, true, true); } /** @@ -172,7 +232,7 @@ public static List> rowListsFromMaps(List> rowM * @param columns keys contained in each map, will copy values associated with them to the resulting list * @return A List> containing values */ - public static List> rowListsFromMaps(List> rowMaps, List columns, boolean includeHeaders, boolean preserveEmptyValues) + public static List> rowListsFromMaps(List> rowMaps, List columns, boolean includeHeaders, boolean allowMissingValues) { List> lists = new ArrayList<>(); @@ -191,11 +251,15 @@ public static List> rowListsFromMaps(List> rowM var value = rowMap.get(column); if (value == null) { - if (preserveEmptyValues) + if (allowMissingValues) value = ""; else throw new IllegalArgumentException("Missing value for column '" + column + "' in row: " + rowMap); } + if (value instanceof Collection c) + { + value = c.stream().map(Object::toString).collect(Collectors.joining(",")); + } rowList.add(value.toString()); } lists.add(rowList); @@ -203,13 +267,59 @@ public static List> rowListsFromMaps(List> rowM return lists; } - public static File writeRowsToTsv(String fileName, List> rows) throws IOException + public static List> replaceColumnHeaders(List> rowLists, Function columnMapper) + { + List headerRow = rowLists.get(0); + List updatedHeaderRow = new ArrayList<>(); + for (String oldHeader : headerRow) + { + updatedHeaderRow.add(columnMapper.apply(oldHeader)); + } + + List> updatedRows = new ArrayList<>(); + updatedRows.add(updatedHeaderRow); + updatedRows.addAll(rowLists.subList(1, rowLists.size())); + + return updatedRows; + } + + public static List> replaceMapKeys(List> rowMaps, Function columnMapper) + { + List> updatedRows = new ArrayList<>(); + for (Map original : rowMaps) + { + Map updatedRow = new LinkedHashMap<>(); + for (Map.Entry entry : original.entrySet()) + { + String updatedKey = columnMapper.apply(entry.getKey()); + if (updatedRow.containsKey(updatedKey)) + { + throw new IllegalArgumentException("Duplicate key mapping for '" + updatedKey + "' in row: " + original); + } + updatedRow.put(updatedKey, entry.getValue()); + } + updatedRows.add(updatedRow); + } + return updatedRows; + } + + public static File writeRowsToTsv(String fileName, List> rows) throws IOException + { + return writeRowsToFile(fileName, rows, CSVFormat.TDF); + } + + public static File writeRowsToCsv(String fileName, List> rows) throws IOException + { + return writeRowsToFile(fileName, rows, CSVFormat.DEFAULT); + } + + public static @NotNull File writeRowsToFile(String fileName, List> rows, CSVFormat format) throws IOException { File file = new File(TestFileUtils.getTestTempDir(), fileName); FileUtils.forceMkdirParent(file); - try (CSVPrinter printer = new CSVPrinter(new FileWriter(file, StandardCharsets.UTF_8), CSVFormat.TDF)) { - for (List row : rows) + try (CSVPrinter printer = new CSVPrinter(new FileWriter(file, StandardCharsets.UTF_8), format)) { + for (List row : rows) { printer.printRecord(row); } @@ -218,17 +328,32 @@ public static File writeRowsToTsv(String fileName, List> rows) thro return file; } - public static String writeRowsToTsvString(List> rows) throws IOException + public static List> readRowsFromTsv(File file) throws IOException { - return writeRowsToString(rows, CSVFormat.TDF); + return readRowsFromFile(file, CSVFormat.TDF); + } + + public static List> readRowsFromCsv(File file) throws IOException + { + return readRowsFromFile(file, CSVFormat.DEFAULT); + } + + public static List> readRowsFromFile(File file, CSVFormat format) throws IOException + { + try (Reader in = new FileReader(file, StandardCharsets.UTF_8)) + { + CSVParser parser = CSVParser.builder().setFormat(format).setReader(in).get(); + List records = parser.getRecords(); + return records.stream().map(CSVRecord::toList).toList(); + } } - public static String writeRowsToString(List> rows, CSVFormat format) + public static String stringFromRows(List> rows, CSVFormat format) { StringWriter stringWriter = new StringWriter(); try (CSVPrinter printer = new CSVPrinter(stringWriter, format)) { - for (List row : rows) + for (List row : rows) { printer.printRecord(row); } diff --git a/src/org/labkey/test/util/query/QueryApiHelper.java b/src/org/labkey/test/util/query/QueryApiHelper.java index 11b7623487..021621e09d 100644 --- a/src/org/labkey/test/util/query/QueryApiHelper.java +++ b/src/org/labkey/test/util/query/QueryApiHelper.java @@ -12,6 +12,7 @@ import org.labkey.remoteapi.query.ImportDataCommand; import org.labkey.remoteapi.query.ImportDataResponse; import org.labkey.remoteapi.query.InsertRowsCommand; +import org.labkey.remoteapi.query.SaveRowsCommand; import org.labkey.remoteapi.query.SaveRowsResponse; import org.labkey.remoteapi.query.SelectRowsCommand; import org.labkey.remoteapi.query.SelectRowsResponse; @@ -89,6 +90,7 @@ public SaveRowsResponse insertRows(List> rows) throws IOExce InsertRowsCommand insertRowsCommand = new InsertRowsCommand(_schema, _query); insertRowsCommand.setRows(rows); insertRowsCommand.setTimeout(_insertTimout); + insertRowsCommand.setAuditBehavior(SaveRowsCommand.AuditBehavior.DETAILED); return insertRowsCommand.execute(_connection, _containerPath); } @@ -97,6 +99,7 @@ public SaveRowsResponse updateRows(List> rows) throws IOExce UpdateRowsCommand updateRowsCommand = new UpdateRowsCommand(_schema, _query); updateRowsCommand.setRows(rows); updateRowsCommand.setTimeout(_insertTimout); + updateRowsCommand.setAuditBehavior(SaveRowsCommand.AuditBehavior.DETAILED); return updateRowsCommand.execute(_connection, _containerPath); } @@ -124,6 +127,7 @@ public SaveRowsResponse deleteRows(List> rowsToDelete) throws { DeleteRowsCommand cmd = new DeleteRowsCommand(_schema, _query); cmd.setRows(rowsToDelete); + cmd.setAuditBehavior(SaveRowsCommand.AuditBehavior.DETAILED); return cmd.execute(_connection, _containerPath); }