diff --git a/api/src/main/java/org/openmrs/module/htmlformentry/element/PersonAttributeSubmissionElement.java b/api/src/main/java/org/openmrs/module/htmlformentry/element/PersonAttributeSubmissionElement.java new file mode 100644 index 000000000..910844912 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/htmlformentry/element/PersonAttributeSubmissionElement.java @@ -0,0 +1,332 @@ +package org.openmrs.module.htmlformentry.element; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import javax.servlet.http.HttpServletRequest; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.openmrs.Concept; +import org.openmrs.ConceptAnswer; +import org.openmrs.Location; +import org.openmrs.LocationTag; +import org.openmrs.Patient; +import org.openmrs.PersonAttribute; +import org.openmrs.PersonAttributeType; +import org.openmrs.api.context.Context; +import org.openmrs.module.htmlformentry.BadFormDesignException; +import org.openmrs.module.htmlformentry.FormEntryContext; +import org.openmrs.module.htmlformentry.FormEntryContext.Mode; +import org.openmrs.module.htmlformentry.FormEntrySession; +import org.openmrs.module.htmlformentry.FormSubmissionError; +import org.openmrs.module.htmlformentry.HtmlFormEntryUtil; +import org.openmrs.module.htmlformentry.action.FormSubmissionControllerAction; +import org.openmrs.module.htmlformentry.comparator.OptionComparator; +import org.openmrs.module.htmlformentry.widget.DropdownWidget; +import org.openmrs.module.htmlformentry.widget.ErrorWidget; +import org.openmrs.module.htmlformentry.widget.Option; +import org.openmrs.module.htmlformentry.widget.TextFieldWidget; +import org.openmrs.module.htmlformentry.widget.Widget; +import org.springframework.util.StringUtils; + +/** + * Holds the widget and submission logic for the {@code } tag. + * + *

Supports three PersonAttributeType formats: + *

+ * + *

When multiple non-voided attributes of the requested type exist for the patient, the most + * recently created one is used and a warning is logged. + */ +public class PersonAttributeSubmissionElement implements HtmlGeneratorElement, FormSubmissionControllerAction { + + protected final Log log = LogFactory.getLog(getClass()); + + /** The resolved attribute type. */ + private final PersonAttributeType attributeType; + + /** The widget used to render and submit the attribute value. */ + private Widget valueWidget; + + /** The error widget (rendered in ENTER / EDIT modes only). */ + private ErrorWidget errorWidget; + + /** The existing non-voided attribute (most recent if there are several), or null. */ + private PersonAttribute existingAttribute; + + public PersonAttributeSubmissionElement(FormEntryContext context, Map parameters) + throws BadFormDesignException { + + // ---- 1. Resolve the PersonAttributeType ---------------------------------------- + String attributeTypeUuid = parameters.get("attributeType"); + if (!StringUtils.hasText(attributeTypeUuid)) { + throw new BadFormDesignException(" tag requires an \"attributeType\" attribute"); + } + attributeType = Context.getPersonService().getPersonAttributeTypeByUuid(attributeTypeUuid); + if (attributeType == null) { + throw new BadFormDesignException( + " tag: PersonAttributeType not found for uuid=\"" + attributeTypeUuid + "\""); + } + + // ---- 2. Find existing attribute on the patient --------------------------------- + Patient patient = context.getExistingPatient(); + if (patient != null) { + List matching = new ArrayList(); + for (PersonAttribute attr : patient.getActiveAttributes()) { + if (attr.getAttributeType().equals(attributeType)) { + matching.add(attr); + } + } + if (matching.size() > 1) { + log.warn("Patient " + patient.getPatientId() + " has " + matching.size() + + " non-voided PersonAttributes of type \"" + attributeType.getName() + + "\"; using most recent."); + matching.sort(Comparator.comparing(PersonAttribute::getDateCreated).reversed()); + } + existingAttribute = matching.isEmpty() ? null : matching.get(0); + } + + // ---- 3. Build widget by format (all modes, including VIEW) -------------------- + errorWidget = new ErrorWidget(); + + if (String.class.getName().equals(attributeType.getFormat())) { + valueWidget = buildStringWidget(context); + + } else if (Concept.class.getName().equals(attributeType.getFormat())) { + valueWidget = buildConceptWidget(context, parameters); + + } else if (Location.class.getName().equals(attributeType.getFormat())) { + valueWidget = buildLocationWidget(context, parameters); + + } else { + throw new BadFormDesignException(" tag: unsupported attribute format \"" + attributeType.getFormat() + + "\" for type \"" + attributeType.getName() + "\". Supported formats: " + + String.class.getName() + ", " + Concept.class.getName() + ", " + Location.class.getName()); + } + + context.registerWidget(valueWidget); + context.registerErrorWidget(valueWidget, errorWidget); + } + + // --------------------------------------------------------------------------------- + // Widget builders + // --------------------------------------------------------------------------------- + + private Widget buildStringWidget(FormEntryContext context) { + TextFieldWidget w = new TextFieldWidget(); + if (existingAttribute != null) { + w.setInitialValue(existingAttribute.getValue()); + } + return w; + } + + private Widget buildConceptWidget(FormEntryContext context, Map parameters) + throws BadFormDesignException { + + DropdownWidget w = new DropdownWidget(); + w.addOption(new Option()); + + for (Concept concept : resolveAnswerConcepts(parameters)) { + w.addOption(new Option(concept.getName(Context.getLocale(), false).getName(), concept.getConceptId().toString(), false)); + } + + if (existingAttribute != null && StringUtils.hasText(existingAttribute.getValue())) { + w.setInitialValue(existingAttribute.getValue()); + } + + return w; + } + + private List resolveAnswerConcepts(Map parameters) throws BadFormDesignException { + String answerConceptIds = parameters.get("answerConceptIds"); + if (StringUtils.hasText(answerConceptIds)) { + return resolveConceptsFromIdList(answerConceptIds); + } + if (attributeType.getForeignKey() != null) { + return resolveConceptsFromForeignKey(attributeType.getForeignKey()); + } + throw new BadFormDesignException( + " tag: PersonAttributeType \"" + attributeType.getName() + + "\" (format=" + Concept.class.getName() + ") requires either an \"answerConceptIds\" " + + "tag attribute or a foreignKey on the attribute type pointing to a concept set or question concept"); + } + + private List resolveConceptsFromIdList(String answerConceptIds) throws BadFormDesignException { + List concepts = new ArrayList<>(); + for (String idOrUuid : answerConceptIds.split(",")) { + String trimmed = idOrUuid.trim(); + if (trimmed.isEmpty()) { + continue; + } + Concept concept = HtmlFormEntryUtil.getConcept(trimmed); + if (concept == null) { + throw new BadFormDesignException( + " tag: cannot find concept for answerConceptIds value \"" + trimmed + "\""); + } + concepts.add(concept); + } + return concepts; + } + + private List resolveConceptsFromForeignKey(Integer foreignKey) throws BadFormDesignException { + Concept fk = Context.getConceptService().getConcept(foreignKey); + if (fk == null) { + throw new BadFormDesignException( + " tag: cannot find concept for foreignKey=" + foreignKey + + " on PersonAttributeType \"" + attributeType.getName() + "\""); + } + if (Boolean.TRUE.equals(fk.getSet())) { + return fk.getSetMembers(false); + } + return fk.getAnswers(false).stream() + .map(ConceptAnswer::getAnswerConcept) + .collect(Collectors.toList()); + } + + private Widget buildLocationWidget(FormEntryContext context, Map parameters) + throws BadFormDesignException { + + DropdownWidget w = new DropdownWidget(); + + // blank / choose prompt + w.addOption(new Option(Context.getMessageSourceService().getMessage("htmlformentry.chooseALocation"), "", false)); + + // Build location list + List locations; + String tagsParam = parameters.get("tags"); + if (StringUtils.hasText(tagsParam)) { + List locationTags = new ArrayList(); + for (String tagName : tagsParam.split(",")) { + String trimmed = tagName.trim(); + if (trimmed.isEmpty()) { + continue; + } + LocationTag tag = HtmlFormEntryUtil.getLocationTag(trimmed); + if (tag == null) { + throw new BadFormDesignException( + " tag: cannot find location tag \"" + trimmed + "\""); + } + locationTags.add(tag); + } + locations = Context.getLocationService().getLocationsHavingAnyTag(locationTags); + } else { + locations = Context.getLocationService().getAllLocations(false); + } + + // Build and sort options + List

Supported attributes: + *

+ */ +public class PersonAttributeTagHandler extends AbstractTagHandler { + + @Override + protected List createAttributeDescriptors() { + List descriptors = new ArrayList(); + descriptors.add(new AttributeDescriptor("attributeType", PersonAttributeType.class)); + descriptors.add(new AttributeDescriptor("answerConceptIds", Concept.class)); + descriptors.add(new AttributeDescriptor("tags", LocationTag.class)); + return Collections.unmodifiableList(descriptors); + } + + @Override + public boolean doStartTag(FormEntrySession session, PrintWriter out, Node parent, Node node) + throws BadFormDesignException { + FormEntryContext context = session.getContext(); + PersonAttributeSubmissionElement element = new PersonAttributeSubmissionElement(context, getAttributes(node)); + session.getSubmissionController().addAction(element); + out.print(element.generateHtml(context)); + return false; // no body to process + } + + @Override + public void doEndTag(FormEntrySession session, PrintWriter out, Node parent, Node node) + throws BadFormDesignException { + // nothing needed + } +} diff --git a/api/src/main/resources/moduleApplicationContext.xml b/api/src/main/resources/moduleApplicationContext.xml index 12061c426..2b4607b8b 100644 --- a/api/src/main/resources/moduleApplicationContext.xml +++ b/api/src/main/resources/moduleApplicationContext.xml @@ -116,6 +116,9 @@ + + + diff --git a/api/src/test/java/org/openmrs/module/htmlformentry/PersonAttributeTagTest.java b/api/src/test/java/org/openmrs/module/htmlformentry/PersonAttributeTagTest.java new file mode 100644 index 000000000..57b2ff273 --- /dev/null +++ b/api/src/test/java/org/openmrs/module/htmlformentry/PersonAttributeTagTest.java @@ -0,0 +1,895 @@ +package org.openmrs.module.htmlformentry; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Date; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.openmrs.Patient; +import org.openmrs.PersonAttribute; +import org.openmrs.PersonAttributeType; +import org.openmrs.api.context.Context; +import org.springframework.mock.web.MockHttpServletRequest; + +/** + * Regression tests for the {@code } tag. + * + *

Covers String, Concept, and Location attribute types in VIEW, ENTER, and EDIT modes, plus + * warning behaviour when multiple non-voided attributes exist, and error cases. + * + *

Standard test data note: + *

    + *
  • PersonAttributeType 2 ("Birthplace") – format {@code java.lang.String}
  • + *
  • PersonAttributeType 8 ("Civil Status") – format {@code org.openmrs.Concept}
  • + *
  • PersonAttributeType 19 ("Birthplace Location") – format {@code org.openmrs.Location} + * (added in RegressionTest-data-openmrs-2.8.xml)
  • + *
  • Patient 7 has NULL-valued Birthplace and Civil Status attributes; no Birthplace Location + * attribute. Used for ENTER/EDIT tests.
  • + *
  • Patient 2 has TWO non-voided Civil Status attributes (ids 21 and 22) pre-loaded in the + * test dataset; id=22 (value="1001"/PENICILLIN) is the most-recent one.
  • + *
+ */ +public class PersonAttributeTagTest extends BaseHtmlFormEntryTest { + + /** PersonAttributeType 2 – Birthplace – format=java.lang.String */ + private static final String STRING_ATTR_TYPE_UUID = "54fc8400-1683-4d71-a1ac-98d40836ff7c"; + + /** PersonAttributeType 8 – Civil Status – format=org.openmrs.Concept */ + private static final String CONCEPT_ATTR_TYPE_UUID = "a0f5521c-dbbd-4c10-81b2-1b7ab18330df"; + + /** PersonAttributeType 19 – Birthplace Location – format=org.openmrs.Location */ + private static final String LOCATION_ATTR_TYPE_UUID = "a6e61b61-5f1a-4a19-8a4c-test00000001"; + + /** PersonAttributeType 20 – format=org.openmrs.Concept, foreignKey → concept set 1004 */ + private static final String CONCEPT_SET_FK_ATTR_TYPE_UUID = "b1e61b61-5f1a-4a19-8a4c-test00000020"; + + /** PersonAttributeType 21 – format=org.openmrs.Concept, foreignKey → question concept 1000 */ + private static final String CONCEPT_ANSWER_FK_ATTR_TYPE_UUID = "c2e61b61-5f1a-4a19-8a4c-test00000021"; + + /** Patient 7 has null-valued attributes and is safe to use for ENTER/EDIT tests. */ + private static final int PATIENT_ID = 7; + + @Before + public void setUp() throws Exception { + executeVersionedDataSet("org/openmrs/module/htmlformentry/data/RegressionTest-data-openmrs-2.8.xml"); + } + + // ========================================================================= + // String attribute type tests + // ========================================================================= + + @Test + public void shouldEnterStringPersonAttribute() throws Exception { + new RegressionTestHelper() { + + @Override + public Patient getPatient() { + return Context.getPatientService().getPatient(PATIENT_ID); + } + + @Override + public String getFormName() { + return "personAttributeStringForm"; + } + + @Override + public String[] widgetLabels() { + return new String[] { "Date:", "Location:", "Provider:", "Birthplace:" }; + } + + @Override + public void setupRequest(MockHttpServletRequest request, Map widgets) { + request.addParameter(widgets.get("Date:"), dateAsString(new Date())); + request.addParameter(widgets.get("Location:"), "2"); + request.addParameter(widgets.get("Provider:"), "502"); + request.addParameter(widgets.get("Birthplace:"), "Paris"); + } + + @Override + public void testResults(SubmissionResults results) { + results.assertNoErrors(); + Patient patient = Context.getPatientService().getPatient(PATIENT_ID); + PersonAttributeType type = Context.getPersonService().getPersonAttributeTypeByUuid(STRING_ATTR_TYPE_UUID); + PersonAttribute attr = patient.getAttribute(type); + assertNotNull("Expected a Birthplace attribute after ENTER", attr); + assertEquals("Paris", attr.getValue()); + } + }.run(); + } + + @Test + public void shouldDisplayStringPersonAttributeInViewMode() throws Exception { + new RegressionTestHelper() { + + @Override + public Patient getPatient() { + return Context.getPatientService().getPatient(PATIENT_ID); + } + + @Override + public String getFormName() { + return "personAttributeStringForm"; + } + + @Override + public String[] widgetLabels() { + return new String[] { "Date:", "Location:", "Provider:", "Birthplace:" }; + } + + @Override + public void setupRequest(MockHttpServletRequest request, Map widgets) { + request.addParameter(widgets.get("Date:"), dateAsString(new Date())); + request.addParameter(widgets.get("Location:"), "2"); + request.addParameter(widgets.get("Provider:"), "502"); + request.addParameter(widgets.get("Birthplace:"), "Tokyo"); + } + + @Override + public boolean doViewPatient() { + return true; + } + + @Override + public void testViewingPatient(Patient patient, String html) { + assertTrue("Expected VIEW HTML to contain the entered value 'Tokyo'", html.contains("Tokyo")); + } + }.run(); + } + + @Test + public void shouldEditStringPersonAttribute() throws Exception { + new RegressionTestHelper() { + + @Override + public Patient getPatient() { + return Context.getPatientService().getPatient(PATIENT_ID); + } + + @Override + public String getFormName() { + return "personAttributeStringForm"; + } + + @Override + public String[] widgetLabels() { + return new String[] { "Date:", "Location:", "Provider:", "Birthplace:" }; + } + + @Override + public void setupRequest(MockHttpServletRequest request, Map widgets) { + request.addParameter(widgets.get("Date:"), dateAsString(new Date())); + request.addParameter(widgets.get("Location:"), "2"); + request.addParameter(widgets.get("Provider:"), "502"); + request.addParameter(widgets.get("Birthplace:"), "Paris"); + } + + @Override + public boolean doEditEncounter() { + return true; + } + + @Override + public String[] widgetLabelsForEdit() { + return widgetLabels(); + } + + @Override + public void setupEditRequest(MockHttpServletRequest request, Map widgets) { + // Use setParameter to replace the pre-populated value from the rendered EDIT form. + request.setParameter(widgets.get("Date:"), dateAsString(new Date())); + request.setParameter(widgets.get("Location:"), "2"); + request.setParameter(widgets.get("Provider:"), "502"); + request.setParameter(widgets.get("Birthplace:"), "Rome"); + } + + @Override + public void testEditedResults(SubmissionResults results) { + results.assertNoErrors(); + Patient patient = Context.getPatientService().getPatient(PATIENT_ID); + PersonAttributeType type = Context.getPersonService().getPersonAttributeTypeByUuid(STRING_ATTR_TYPE_UUID); + PersonAttribute attr = patient.getAttribute(type); + assertNotNull("Expected a Birthplace attribute after EDIT", attr); + assertEquals("Rome", attr.getValue()); + } + }.run(); + } + + @Test + public void shouldVoidStringPersonAttributeWhenBlankValueSubmitted() throws Exception { + new RegressionTestHelper() { + + @Override + public Patient getPatient() { + return Context.getPatientService().getPatient(PATIENT_ID); + } + + @Override + public String getFormName() { + return "personAttributeStringForm"; + } + + @Override + public String[] widgetLabels() { + return new String[] { "Date:", "Location:", "Provider:", "Birthplace:" }; + } + + @Override + public void setupRequest(MockHttpServletRequest request, Map widgets) { + request.addParameter(widgets.get("Date:"), dateAsString(new Date())); + request.addParameter(widgets.get("Location:"), "2"); + request.addParameter(widgets.get("Provider:"), "502"); + request.addParameter(widgets.get("Birthplace:"), "Paris"); + } + + @Override + public boolean doEditEncounter() { + return true; + } + + @Override + public String[] widgetLabelsForEdit() { + return widgetLabels(); + } + + @Override + public void setupEditRequest(MockHttpServletRequest request, Map widgets) { + // Use setParameter to replace the pre-populated value from the rendered EDIT form. + request.setParameter(widgets.get("Date:"), dateAsString(new Date())); + request.setParameter(widgets.get("Location:"), "2"); + request.setParameter(widgets.get("Provider:"), "502"); + // Submit blank value → should void the existing attribute + request.setParameter(widgets.get("Birthplace:"), ""); + } + + @Override + public void testEditedResults(SubmissionResults results) { + results.assertNoErrors(); + Patient patient = Context.getPatientService().getPatient(PATIENT_ID); + PersonAttributeType type = Context.getPersonService().getPersonAttributeTypeByUuid(STRING_ATTR_TYPE_UUID); + PersonAttribute attr = patient.getAttribute(type); + assertNull("Expected no active Birthplace attribute after blank submission (should be voided)", attr); + } + }.run(); + } + + // ========================================================================= + // Concept attribute type tests + // ========================================================================= + + @Test + public void shouldEnterConceptPersonAttribute() throws Exception { + new RegressionTestHelper() { + + @Override + public Patient getPatient() { + return Context.getPatientService().getPatient(PATIENT_ID); + } + + @Override + public String getFormName() { + return "personAttributeConceptForm"; + } + + @Override + public String[] widgetLabels() { + return new String[] { "Date:", "Location:", "Provider:", "Civil Status:" }; + } + + @Override + public void setupRequest(MockHttpServletRequest request, Map widgets) { + request.addParameter(widgets.get("Date:"), dateAsString(new Date())); + request.addParameter(widgets.get("Location:"), "2"); + request.addParameter(widgets.get("Provider:"), "502"); + // Concept 1001 = PENICILLIN (from answerConceptIds in the form) + request.addParameter(widgets.get("Civil Status:"), "1001"); + } + + @Override + public void testResults(SubmissionResults results) { + results.assertNoErrors(); + Patient patient = Context.getPatientService().getPatient(PATIENT_ID); + PersonAttributeType type = Context.getPersonService().getPersonAttributeTypeByUuid(CONCEPT_ATTR_TYPE_UUID); + PersonAttribute attr = patient.getAttribute(type); + assertNotNull("Expected a Civil Status attribute after ENTER", attr); + assertEquals("Stored value should be the concept ID", "1001", attr.getValue()); + } + }.run(); + } + + @Test + public void shouldDisplayConceptPersonAttributeNameInViewMode() throws Exception { + new RegressionTestHelper() { + + @Override + public Patient getPatient() { + return Context.getPatientService().getPatient(PATIENT_ID); + } + + @Override + public String getFormName() { + return "personAttributeConceptForm"; + } + + @Override + public String[] widgetLabels() { + return new String[] { "Date:", "Location:", "Provider:", "Civil Status:" }; + } + + @Override + public void setupRequest(MockHttpServletRequest request, Map widgets) { + request.addParameter(widgets.get("Date:"), dateAsString(new Date())); + request.addParameter(widgets.get("Location:"), "2"); + request.addParameter(widgets.get("Provider:"), "502"); + request.addParameter(widgets.get("Civil Status:"), "1001"); + } + + @Override + public boolean doViewPatient() { + return true; + } + + @Override + public void testViewingPatient(Patient patient, String html) { + // Concept 1001 is named "PENICILLIN" in test data + assertTrue("Expected VIEW HTML to contain the concept name 'PENICILLIN'", html.contains("PENICILLIN")); + } + }.run(); + } + + @Test + public void shouldEditConceptPersonAttribute() throws Exception { + new RegressionTestHelper() { + + @Override + public Patient getPatient() { + return Context.getPatientService().getPatient(PATIENT_ID); + } + + @Override + public String getFormName() { + return "personAttributeConceptForm"; + } + + @Override + public String[] widgetLabels() { + return new String[] { "Date:", "Location:", "Provider:", "Civil Status:" }; + } + + @Override + public void setupRequest(MockHttpServletRequest request, Map widgets) { + request.addParameter(widgets.get("Date:"), dateAsString(new Date())); + request.addParameter(widgets.get("Location:"), "2"); + request.addParameter(widgets.get("Provider:"), "502"); + request.addParameter(widgets.get("Civil Status:"), "1001"); + } + + @Override + public boolean doEditEncounter() { + return true; + } + + @Override + public String[] widgetLabelsForEdit() { + return widgetLabels(); + } + + @Override + public void setupEditRequest(MockHttpServletRequest request, Map widgets) { + // Use setParameter to replace the pre-populated value from the rendered EDIT form. + request.setParameter(widgets.get("Date:"), dateAsString(new Date())); + request.setParameter(widgets.get("Location:"), "2"); + request.setParameter(widgets.get("Provider:"), "502"); + request.setParameter(widgets.get("Civil Status:"), "1002"); + } + + @Override + public void testEditedResults(SubmissionResults results) { + results.assertNoErrors(); + Patient patient = Context.getPatientService().getPatient(PATIENT_ID); + PersonAttributeType type = Context.getPersonService().getPersonAttributeTypeByUuid(CONCEPT_ATTR_TYPE_UUID); + PersonAttribute attr = patient.getAttribute(type); + assertNotNull("Expected a Civil Status attribute after EDIT", attr); + assertEquals("1002", attr.getValue()); + } + }.run(); + } + + /** + * When a Concept-type PersonAttributeType has a foreignKey pointing to a concept set, + * the dropdown should contain the set's non-retired members and submission should save + * the selected member's concept ID. + * + *

PersonAttributeType 20 has foreignKey=1004 (concept set "ANOTHER ALLERGY CONSTRUCT"). + * Set members: 80000/ALLERGY, 1119/ALLERGY DATE, 1000/ALLERGY CODED, 1005/HYPER-ALLERGY CODED. + */ + @Test + public void shouldBuildConceptDropdownFromForeignKeyConceptSet() throws Exception { + new RegressionTestHelper() { + + @Override + public Patient getPatient() { + return Context.getPatientService().getPatient(PATIENT_ID); + } + + @Override + public String getFormName() { + return "personAttributeConceptSetForeignKeyForm"; + } + + @Override + public String[] widgetLabels() { + return new String[] { "Date:", "Location:", "Provider:", "Attr:" }; + } + + @Override + public void testBlankFormHtml(String html) { + // Verify that set members appear as dropdown options + assertTrue("Expected ALLERGY DATE (concept 1119) in dropdown", html.contains("ALLERGY DATE")); + assertTrue("Expected HYPER-ALLERGY CODED (concept 1005) in dropdown", html.contains("HYPER-ALLERGY CODED")); + } + + @Override + public void setupRequest(MockHttpServletRequest request, Map widgets) { + request.addParameter(widgets.get("Date:"), dateAsString(new Date())); + request.addParameter(widgets.get("Location:"), "2"); + request.addParameter(widgets.get("Provider:"), "502"); + // Submit concept 80000 (ALLERGY) — a member of set 1004 + request.addParameter(widgets.get("Attr:"), "80000"); + } + + @Override + public void testResults(SubmissionResults results) { + results.assertNoErrors(); + Patient patient = Context.getPatientService().getPatient(PATIENT_ID); + PersonAttributeType type = Context.getPersonService() + .getPersonAttributeTypeByUuid(CONCEPT_SET_FK_ATTR_TYPE_UUID); + PersonAttribute attr = patient.getAttribute(type); + assertNotNull("Expected attribute to be created after ENTER", attr); + assertEquals("Stored value should be the submitted concept ID", "80000", attr.getValue()); + } + }.run(); + } + + /** + * When a Concept-type PersonAttributeType has a foreignKey pointing to a question concept, + * the dropdown should contain that concept's non-retired answer concepts and submission + * should save the selected answer concept's ID. + * + *

PersonAttributeType 21 has foreignKey=1000 (concept "ALLERGY CODED", not a set). + * Answers: 1001/PENICILLIN, 1002/CATS, 1003/OPENMRS. + */ + @Test + public void shouldBuildConceptDropdownFromForeignKeyConceptAnswers() throws Exception { + new RegressionTestHelper() { + + @Override + public Patient getPatient() { + return Context.getPatientService().getPatient(PATIENT_ID); + } + + @Override + public String getFormName() { + return "personAttributeConceptAnswerForeignKeyForm"; + } + + @Override + public String[] widgetLabels() { + return new String[] { "Date:", "Location:", "Provider:", "Attr:" }; + } + + @Override + public void testBlankFormHtml(String html) { + // Verify that answer concepts appear as dropdown options + assertTrue("Expected PENICILLIN (answer concept 1001) in dropdown", html.contains("PENICILLIN")); + assertTrue("Expected CATS (answer concept 1002) in dropdown", html.contains("CATS")); + assertTrue("Expected OPENMRS (answer concept 1003) in dropdown", html.contains("OPENMRS")); + } + + @Override + public void setupRequest(MockHttpServletRequest request, Map widgets) { + request.addParameter(widgets.get("Date:"), dateAsString(new Date())); + request.addParameter(widgets.get("Location:"), "2"); + request.addParameter(widgets.get("Provider:"), "502"); + // Submit concept 1002 (CATS) — an answer of question concept 1000 + request.addParameter(widgets.get("Attr:"), "1002"); + } + + @Override + public void testResults(SubmissionResults results) { + results.assertNoErrors(); + Patient patient = Context.getPatientService().getPatient(PATIENT_ID); + PersonAttributeType type = Context.getPersonService() + .getPersonAttributeTypeByUuid(CONCEPT_ANSWER_FK_ATTR_TYPE_UUID); + PersonAttribute attr = patient.getAttribute(type); + assertNotNull("Expected attribute to be created after ENTER", attr); + assertEquals("Stored value should be the submitted concept ID", "1002", attr.getValue()); + } + }.run(); + } + + // ========================================================================= + // Location attribute type tests + // ========================================================================= + + @Test + public void shouldEnterLocationPersonAttribute() throws Exception { + new RegressionTestHelper() { + + @Override + public Patient getPatient() { + return Context.getPatientService().getPatient(PATIENT_ID); + } + + @Override + public String getFormName() { + return "personAttributeLocationForm"; + } + + @Override + public String[] widgetLabels() { + return new String[] { "Date:", "Location:", "Provider:", "Birthplace Location:" }; + } + + @Override + public void setupRequest(MockHttpServletRequest request, Map widgets) { + request.addParameter(widgets.get("Date:"), dateAsString(new Date())); + request.addParameter(widgets.get("Location:"), "2"); + request.addParameter(widgets.get("Provider:"), "502"); + // Location 1001 = Kigali + request.addParameter(widgets.get("Birthplace Location:"), "1001"); + } + + @Override + public void testResults(SubmissionResults results) { + results.assertNoErrors(); + Patient patient = Context.getPatientService().getPatient(PATIENT_ID); + PersonAttributeType type = Context.getPersonService().getPersonAttributeTypeByUuid(LOCATION_ATTR_TYPE_UUID); + PersonAttribute attr = patient.getAttribute(type); + assertNotNull("Expected a Birthplace Location attribute after ENTER", attr); + assertEquals("Stored value should be the location ID", "1001", attr.getValue()); + } + }.run(); + } + + @Test + public void shouldDisplayLocationPersonAttributeNameInViewMode() throws Exception { + new RegressionTestHelper() { + + @Override + public Patient getPatient() { + return Context.getPatientService().getPatient(PATIENT_ID); + } + + @Override + public String getFormName() { + return "personAttributeLocationForm"; + } + + @Override + public String[] widgetLabels() { + return new String[] { "Date:", "Location:", "Provider:", "Birthplace Location:" }; + } + + @Override + public void setupRequest(MockHttpServletRequest request, Map widgets) { + request.addParameter(widgets.get("Date:"), dateAsString(new Date())); + request.addParameter(widgets.get("Location:"), "2"); + request.addParameter(widgets.get("Provider:"), "502"); + request.addParameter(widgets.get("Birthplace Location:"), "1001"); + } + + @Override + public boolean doViewPatient() { + return true; + } + + @Override + public void testViewingPatient(Patient patient, String html) { + // Location 1001 = Kigali + assertTrue("Expected VIEW HTML to contain the location name 'Kigali'", html.contains("Kigali")); + } + }.run(); + } + + @Test + public void shouldEditLocationPersonAttribute() throws Exception { + new RegressionTestHelper() { + + @Override + public Patient getPatient() { + return Context.getPatientService().getPatient(PATIENT_ID); + } + + @Override + public String getFormName() { + return "personAttributeLocationForm"; + } + + @Override + public String[] widgetLabels() { + return new String[] { "Date:", "Location:", "Provider:", "Birthplace Location:" }; + } + + @Override + public void setupRequest(MockHttpServletRequest request, Map widgets) { + request.addParameter(widgets.get("Date:"), dateAsString(new Date())); + request.addParameter(widgets.get("Location:"), "2"); + request.addParameter(widgets.get("Provider:"), "502"); + request.addParameter(widgets.get("Birthplace Location:"), "1001"); + } + + @Override + public boolean doEditEncounter() { + return true; + } + + @Override + public String[] widgetLabelsForEdit() { + return widgetLabels(); + } + + @Override + public void setupEditRequest(MockHttpServletRequest request, Map widgets) { + // Use setParameter to replace the pre-populated value from the rendered EDIT form. + request.setParameter(widgets.get("Date:"), dateAsString(new Date())); + request.setParameter(widgets.get("Location:"), "2"); + request.setParameter(widgets.get("Provider:"), "502"); + // Change to location 1002 = Mirebalais + request.setParameter(widgets.get("Birthplace Location:"), "1002"); + } + + @Override + public void testEditedResults(SubmissionResults results) { + results.assertNoErrors(); + Patient patient = Context.getPatientService().getPatient(PATIENT_ID); + PersonAttributeType type = Context.getPersonService().getPersonAttributeTypeByUuid(LOCATION_ATTR_TYPE_UUID); + PersonAttribute attr = patient.getAttribute(type); + assertNotNull("Expected a Birthplace Location attribute after EDIT", attr); + assertEquals("1002", attr.getValue()); + } + }.run(); + } + + /** + * When the {@code tags} attribute is specified on a Location-type personAttribute, only locations + * tagged with the specified tag(s) should appear in the dropdown. This form omits + * {@code } to avoid its full location list contaminating the assertions. + */ + @Test + public void shouldFilterLocationDropdownByLocationTag() throws Exception { + new RegressionTestHelper() { + + @Override + public Patient getPatient() { + return Context.getPatientService().getPatient(PATIENT_ID); + } + + @Override + public String getFormName() { + return "personAttributeLocationFilterOnlyForm"; + } + + @Override + public void testBlankFormHtml(String html) { + // Locations tagged with "Some Tag": 1001 = Kigali, 1002 = Mirebalais + assertTrue("Expected Kigali (tagged 'Some Tag') in dropdown", html.contains("Kigali")); + assertTrue("Expected Mirebalais (tagged 'Some Tag') in dropdown", html.contains("Mirebalais")); + // Locations with ONLY "Another Tag": Boston (1004), Scituate (1005) must NOT appear + assertFalse("Expected Boston (not tagged 'Some Tag') to be absent", html.contains("Boston")); + assertFalse("Expected Scituate (not tagged 'Some Tag') to be absent", html.contains("Scituate")); + } + }.run(); + } + + // ========================================================================= + // Multiple non-voided attributes – warning and most-recent selection + // ========================================================================= + + /** + * When a patient has more than one non-voided attribute of the same type, the element should + * render successfully (logging a warning) and use the most recently created attribute. + * + *

Patient 2 has two Civil Status attributes pre-loaded in the test dataset: + *

    + *
  • id=21: value="6", date_created=2020-01-01 (older)
  • + *
  • id=22: value="1001" (PENICILLIN), date_created=2024-01-01 (more recent)
  • + *
+ */ + @Test + public void shouldUseMostRecentAttributeWhenMultipleNonVoidedExistInViewMode() throws Exception { + // Patient 2 already has two non-voided Civil Status attributes in the test dataset. + // The most-recent one (id=22, value="1001"/PENICILLIN) should be displayed in VIEW mode. + final Patient viewPatient = Context.getPatientService().getPatient(2); + + new RegressionTestHelper() { + + /** Use patient 7 for the required ENTER phase; patient 2 for VIEW. */ + @Override + public Patient getPatient() { + return Context.getPatientService().getPatient(PATIENT_ID); + } + + @Override + public Patient getPatientToView() { + return viewPatient; + } + + @Override + public String getFormName() { + return "personAttributeConceptForm"; + } + + @Override + public String[] widgetLabels() { + return new String[] { "Date:", "Location:", "Provider:", "Civil Status:" }; + } + + @Override + public void setupRequest(MockHttpServletRequest request, Map widgets) { + // A valid ENTER for patient 7; the Civil Status attribute is intentionally left blank + // so patient 7's data is not affected. + request.addParameter(widgets.get("Date:"), dateAsString(new Date())); + request.addParameter(widgets.get("Location:"), "2"); + request.addParameter(widgets.get("Provider:"), "502"); + request.addParameter(widgets.get("Civil Status:"), ""); + } + + @Override + public void testViewingPatient(Patient patient, String html) { + // The most-recent attribute (value "1001" / PENICILLIN) should be displayed. + // The form must render without exception even with two active attributes. + assertNotNull("Expected non-null HTML when multiple attributes exist", html); + assertTrue("Expected most-recent attribute name 'PENICILLIN' in VIEW HTML", + html.contains("PENICILLIN")); + } + }.run(); + } + + /** + * When a patient has multiple non-voided attributes of the same type and the form is edited, + * the most-recently-created attribute should be the one updated. + * + *

Patient 2 has two Civil Status attributes pre-loaded in the test dataset: + *

    + *
  • id=21: value="6", date_created=2020-01-01 (older)
  • + *
  • id=22: value="1001" (PENICILLIN), date_created=2024-01-01 (more recent)
  • + *
+ */ + @Test + public void shouldUseMostRecentAttributeWhenMultipleNonVoidedExistInEditMode() throws Exception { + // Patient 2 already has two non-voided Civil Status attributes in the test dataset. + final PersonAttributeType civilStatusType = Context.getPersonService().getPersonAttributeType(8); + final Patient patient2 = Context.getPatientService().getPatient(2); + + new RegressionTestHelper() { + + /** Use patient 2 (with multiple attributes) for the whole test. */ + @Override + public Patient getPatient() { + return patient2; + } + + @Override + public String getFormName() { + return "personAttributeConceptForm"; + } + + @Override + public String[] widgetLabels() { + return new String[] { "Date:", "Location:", "Provider:", "Civil Status:" }; + } + + @Override + public void setupRequest(MockHttpServletRequest request, Map widgets) { + // ENTER: update the most-recent attribute (value="1001") to "1002" + request.addParameter(widgets.get("Date:"), dateAsString(new Date())); + request.addParameter(widgets.get("Location:"), "2"); + request.addParameter(widgets.get("Provider:"), "502"); + request.addParameter(widgets.get("Civil Status:"), "1002"); + } + + @Override + public void testResults(SubmissionResults results) { + results.assertNoErrors(); + // Most-recent attribute should now be "1002" + Patient patient = Context.getPatientService().getPatient(2); + boolean found = patient.getActiveAttributes().stream() + .filter(a -> a.getAttributeType().equals(civilStatusType)) + .anyMatch(a -> "1002".equals(a.getValue())); + assertTrue("Expected the most-recent attribute to be updated to '1002'", found); + } + + @Override + public boolean doEditEncounter() { + return true; + } + + @Override + public String[] widgetLabelsForEdit() { + return widgetLabels(); + } + + @Override + public void setupEditRequest(MockHttpServletRequest request, Map widgets) { + // EDIT: update the most-recent attribute (now "1002") to "1003". + // Use setParameter to replace the pre-populated value from the rendered EDIT form. + request.setParameter(widgets.get("Date:"), dateAsString(new Date())); + request.setParameter(widgets.get("Location:"), "2"); + request.setParameter(widgets.get("Provider:"), "502"); + request.setParameter(widgets.get("Civil Status:"), "1003"); + } + + @Override + public void testEditedResults(SubmissionResults results) { + results.assertNoErrors(); + Patient patient = Context.getPatientService().getPatient(2); + boolean found = patient.getActiveAttributes().stream() + .filter(a -> a.getAttributeType().equals(civilStatusType)) + .anyMatch(a -> "1003".equals(a.getValue())); + assertTrue("Expected the most-recent attribute to be updated to '1003' after EDIT", found); + } + }.run(); + } + + // ========================================================================= + // Form design error cases + // ========================================================================= + + /** + * When an unknown {@code attributeType} UUID is provided, the framework renders a form-design + * error div rather than propagating the exception (this is the standard HFE behaviour for + * {@link BadFormDesignException} thrown from a tag handler). + */ + @Test + public void shouldRenderFormDesignErrorForUnknownAttributeTypeUuid() throws Exception { + String xml = "" + + ""; + Patient patient = Context.getPatientService().getPatient(PATIENT_ID); + FormEntrySession session = new FormEntrySession(patient, xml, null); + String html = session.getHtmlToDisplay(); + assertTrue("Expected form-design error to be rendered in HTML", html.contains("error")); + } + + /** + * A Concept-type PersonAttributeType with neither {@code answerConceptIds} on the tag + * nor a {@code foreignKey} set on the attribute type results in a rendered form-design error. + * Civil Status (type 8) has no foreignKey, so this case exercises the third branch. + */ + @Test + public void shouldRenderFormDesignErrorForConceptTypeWithoutAnswerConceptIdsOrForeignKey() throws Exception { + // Civil Status (type 8) has format=org.openmrs.Concept and no foreignKey set. + String xml = "" + + ""; + Patient patient = Context.getPatientService().getPatient(PATIENT_ID); + FormEntrySession session = new FormEntrySession(patient, xml, null); + String html = session.getHtmlToDisplay(); + assertTrue("Expected form-design error to be rendered in HTML", html.contains("error")); + } + + /** + * A {@code } tag without any {@code attributeType} attribute results in a + * rendered form-design error. + */ + @Test + public void shouldRenderFormDesignErrorWhenAttributeTypeAttributeIsMissing() throws Exception { + String xml = "" + + ""; + Patient patient = Context.getPatientService().getPatient(PATIENT_ID); + FormEntrySession session = new FormEntrySession(patient, xml, null); + String html = session.getHtmlToDisplay(); + assertTrue("Expected form-design error to be rendered in HTML", html.contains("error")); + } + + // ========================================================================= + // Helper + // ========================================================================= + + private static void assertFalse(String message, boolean condition) { + assertTrue(message, !condition); + } +} diff --git a/api/src/test/resources/org/openmrs/module/htmlformentry/data/RegressionTest-data-openmrs-2.8.xml b/api/src/test/resources/org/openmrs/module/htmlformentry/data/RegressionTest-data-openmrs-2.8.xml index e8f0bc418..306c6f0ae 100644 --- a/api/src/test/resources/org/openmrs/module/htmlformentry/data/RegressionTest-data-openmrs-2.8.xml +++ b/api/src/test/resources/org/openmrs/module/htmlformentry/data/RegressionTest-data-openmrs-2.8.xml @@ -299,8 +299,37 @@ + + + + + + + + + + + + + @@ -323,4 +352,10 @@ + + + diff --git a/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeConceptAnswerForeignKeyForm.xml b/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeConceptAnswerForeignKeyForm.xml new file mode 100644 index 000000000..d8cc12a8b --- /dev/null +++ b/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeConceptAnswerForeignKeyForm.xml @@ -0,0 +1,7 @@ + + Date: + Location: + Provider: + Attr: + + diff --git a/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeConceptForm.xml b/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeConceptForm.xml new file mode 100644 index 000000000..72f060c80 --- /dev/null +++ b/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeConceptForm.xml @@ -0,0 +1,7 @@ + + Date: + Location: + Provider: + Civil Status: + + diff --git a/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeConceptSetForeignKeyForm.xml b/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeConceptSetForeignKeyForm.xml new file mode 100644 index 000000000..cdb2ccf3e --- /dev/null +++ b/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeConceptSetForeignKeyForm.xml @@ -0,0 +1,7 @@ + + Date: + Location: + Provider: + Attr: + + diff --git a/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeLocationFilterOnlyForm.xml b/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeLocationFilterOnlyForm.xml new file mode 100644 index 000000000..3429651ae --- /dev/null +++ b/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeLocationFilterOnlyForm.xml @@ -0,0 +1,6 @@ + + Date: + Provider: + Birthplace Location: + + diff --git a/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeLocationForm.xml b/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeLocationForm.xml new file mode 100644 index 000000000..8739a08fa --- /dev/null +++ b/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeLocationForm.xml @@ -0,0 +1,7 @@ + + Date: + Location: + Provider: + Birthplace Location: + + diff --git a/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeLocationWithTagForm.xml b/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeLocationWithTagForm.xml new file mode 100644 index 000000000..3cdcf65e3 --- /dev/null +++ b/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeLocationWithTagForm.xml @@ -0,0 +1,7 @@ + + Date: + Location: + Provider: + Birthplace Location: + + diff --git a/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeStringForm.xml b/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeStringForm.xml new file mode 100644 index 000000000..d780c0467 --- /dev/null +++ b/api/src/test/resources/org/openmrs/module/htmlformentry/include/personAttributeStringForm.xml @@ -0,0 +1,7 @@ + + Date: + Location: + Provider: + Birthplace: + +