diff --git a/api/src/main/java/org/openmrs/module/htmlformentry/element/AppointmentServiceObsElement.java b/api/src/main/java/org/openmrs/module/htmlformentry/element/AppointmentServiceObsElement.java new file mode 100644 index 000000000..07a5dc42d --- /dev/null +++ b/api/src/main/java/org/openmrs/module/htmlformentry/element/AppointmentServiceObsElement.java @@ -0,0 +1,139 @@ +package org.openmrs.module.htmlformentry.element; + +import javax.servlet.http.HttpServletRequest; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.apache.commons.lang.StringUtils; +import org.openmrs.Concept; +import org.openmrs.Obs; +import org.openmrs.api.context.Context; +import org.openmrs.module.appointments.model.AppointmentServiceDefinition; +import org.openmrs.module.appointments.service.AppointmentServiceDefinitionService; +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.tag.TagUtil; +import org.openmrs.module.htmlformentry.widget.DropdownWidget; +import org.openmrs.module.htmlformentry.widget.ErrorWidget; +import org.openmrs.module.htmlformentry.widget.Option; + +public class AppointmentServiceObsElement implements HtmlGeneratorElement, FormSubmissionControllerAction { + + private Concept concept; + + private Obs existingObs; + + DropdownWidget serviceWidget; + + private ErrorWidget errorWidget; + + private String tagControlId; + + public AppointmentServiceObsElement(FormEntryContext context, Map parameters) throws BadFormDesignException{ + String conceptId = parameters.get("conceptId"); + if (conceptId == null) { + throw new BadFormDesignException("appointmentServiceObs requires a conceptId parameter"); + } + concept = HtmlFormEntryUtil.getConcept(conceptId); + if (concept == null) { + throw new BadFormDesignException("Could not find concept: " + conceptId); + } + + if (parameters.containsKey("controlId")) { + tagControlId = parameters.get("controlId"); + } + + boolean insideObsGroup = context.getCurrentObsGroupConcepts() != null + && context.getCurrentObsGroupConcepts().size() > 0; + if (StringUtils.isNotBlank(tagControlId)) { + if (insideObsGroup) { + existingObs = context.getObsFromCurrentGroup(tagControlId); + } else { + existingObs = context.getObsFromExistingObs(concept, tagControlId); + } + } else { + if (insideObsGroup) { + existingObs = context.getObsFromCurrentGroup(concept, (Concept) null); + } else { + existingObs = context.removeExistingObs(concept, (Concept) null); + } + } + String existingValue = existingObs != null ? existingObs.getValueText() : null; + + List specialityFilter = TagUtil.parseListParameter(parameters, "specialities", String.class).stream() + .map(String::toLowerCase).map(String::trim).collect(Collectors.toList()); + + List services = Context.getService(AppointmentServiceDefinitionService.class) + .getAllAppointmentServices(false); + services.sort((a, b) -> a.getName().compareToIgnoreCase(b.getName())); + + if (!specialityFilter.isEmpty()) { + services = services.stream() + .filter(s -> s.getSpeciality() != null + && (specialityFilter.contains(s.getSpeciality().getName().toLowerCase().trim()) + || specialityFilter.contains(s.getSpeciality().getUuid().toLowerCase().trim()))) + .collect(Collectors.toList()); + } + + serviceWidget = new DropdownWidget(); + errorWidget = new ErrorWidget(); + + serviceWidget.addOption(new Option("", "", false)); + for (AppointmentServiceDefinition svc : services) { + serviceWidget.addOption(new Option(svc.getName(), svc.getUuid(), false)); + } + if (existingValue != null) { + serviceWidget.setInitialValue(existingValue); + } + + context.registerWidget(serviceWidget); + context.registerErrorWidget(serviceWidget, errorWidget); + } + + @Override + public String generateHtml(FormEntryContext context) { + StringBuilder sb = new StringBuilder(); + sb.append(serviceWidget.generateHtml(context)); + if (context.getMode() != Mode.VIEW) { + sb.append(errorWidget.generateHtml(context)); + } + return sb.toString(); + } + + @Override + public Collection validateSubmission(FormEntryContext context, HttpServletRequest submission) { + return Collections.emptyList(); + } + + @Override + public void handleSubmission(FormEntrySession session, HttpServletRequest submission) { + if (session.getContext().getMode() == Mode.VIEW) { + return; + } + + FormEntryContext context = session.getContext(); + String value = (String) serviceWidget.getValue(context, submission); + + if (existingObs != null && context.getMode() == Mode.EDIT) { + session.getSubmissionActions().modifyObs(existingObs, concept, value, null, null, null, getControlFormPath(session)); + } else if (value != null && !value.isEmpty()) { + session.getSubmissionActions().createObs(concept, value, null, null, null, getControlFormPath(session)); + } + } + + private String getControlFormPath(FormEntrySession session) { + if (StringUtils.isBlank(tagControlId)) { + return null; + } + return session.generateControlFormPath(tagControlId, 0); + } + +} diff --git a/api/src/main/java/org/openmrs/module/htmlformentry/handler/AppointmentServiceObsTagHandler.java b/api/src/main/java/org/openmrs/module/htmlformentry/handler/AppointmentServiceObsTagHandler.java new file mode 100644 index 000000000..1a8151c82 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/htmlformentry/handler/AppointmentServiceObsTagHandler.java @@ -0,0 +1,27 @@ +package org.openmrs.module.htmlformentry.handler; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.openmrs.Concept; +import org.openmrs.module.htmlformentry.BadFormDesignException; +import org.openmrs.module.htmlformentry.FormEntrySession; +import org.openmrs.module.htmlformentry.FormSubmissionController; +import org.openmrs.module.htmlformentry.element.AppointmentServiceObsElement; + +public class AppointmentServiceObsTagHandler extends SubstitutionTagHandler { + + @Override + protected List createAttributeDescriptors() { + return Collections.singletonList(new AttributeDescriptor("conceptId", Concept.class)); + } + + @Override + protected String getSubstitution(FormEntrySession session, FormSubmissionController controllerActions, + Map parameters) throws BadFormDesignException { + AppointmentServiceObsElement element = new AppointmentServiceObsElement(session.getContext(), parameters); + session.getSubmissionController().addAction(element); + return element.generateHtml(session.getContext()); + } +} diff --git a/api/src/main/resources/moduleApplicationContext.xml b/api/src/main/resources/moduleApplicationContext.xml index 2b4607b8b..9299063b7 100644 --- a/api/src/main/resources/moduleApplicationContext.xml +++ b/api/src/main/resources/moduleApplicationContext.xml @@ -119,6 +119,9 @@ + + + diff --git a/api/src/test/java/org/openmrs/module/htmlformentry/element/AppointmentServiceObsElementTest.java b/api/src/test/java/org/openmrs/module/htmlformentry/element/AppointmentServiceObsElementTest.java new file mode 100644 index 000000000..994ddb03c --- /dev/null +++ b/api/src/test/java/org/openmrs/module/htmlformentry/element/AppointmentServiceObsElementTest.java @@ -0,0 +1,394 @@ +package org.openmrs.module.htmlformentry.element; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import javax.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.MockitoJUnitRunner; +import org.openmrs.Concept; +import org.openmrs.ConceptDatatype; +import org.openmrs.Form; +import org.openmrs.Obs; +import org.openmrs.Patient; +import org.openmrs.api.context.Context; +import org.openmrs.module.appointments.model.AppointmentServiceDefinition; +import org.openmrs.module.appointments.model.Speciality; +import org.openmrs.module.appointments.service.AppointmentServiceDefinitionService; +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.FormSubmissionActions; +import org.openmrs.module.htmlformentry.HtmlFormEntryUtil; +import org.springframework.mock.web.MockHttpServletRequest; + +@RunWith(MockitoJUnitRunner.class) +public class AppointmentServiceObsElementTest { + + private static final String SERVICE_UUID = "service-uuid-1"; + + private static final String CONCEPT_ID = "5090"; + + @Mock + private FormEntryContext context; + + @Mock + private FormEntrySession session; + + @Mock + private AppointmentServiceDefinitionService appointmentServiceDefinitionService; + + private FormSubmissionActions actions; + + private MockedStatic mockedContext; + + private MockedStatic mockedHtmlFormEntryUtil; + + private Concept concept; + + @Before + public void setup() throws Exception { + mockedContext = mockStatic(Context.class); + mockedHtmlFormEntryUtil = mockStatic(HtmlFormEntryUtil.class); + + ConceptDatatype textDatatype = new ConceptDatatype(); + textDatatype.setUuid(ConceptDatatype.TEXT_UUID); + concept = new Concept(Integer.parseInt(CONCEPT_ID)); + concept.setDatatype(textDatatype); + + mockedHtmlFormEntryUtil.when(() -> HtmlFormEntryUtil.getConcept(CONCEPT_ID)).thenReturn(concept); + mockedHtmlFormEntryUtil.when(() -> HtmlFormEntryUtil.createObs(any(Concept.class), any(), any(), any())).thenCallRealMethod(); + mockedContext.when(() -> Context.getService(AppointmentServiceDefinitionService.class)) + .thenReturn(appointmentServiceDefinitionService); + mockedContext.when(Context::getLocale).thenReturn(Locale.ENGLISH); + + when(appointmentServiceDefinitionService.getAllAppointmentServices(false)) + .thenReturn(Collections.singletonList(makeService("Cardiology", 1, SERVICE_UUID, null))); + + when(context.getMode()).thenReturn(Mode.ENTER); + when(session.getContext()).thenReturn(context); + + actions = new FormSubmissionActions(); + actions.beginPerson(new Patient(1)); + when(session.getSubmissionActions()).thenReturn(actions); + } + + @After + public void teardown() { + mockedContext.close(); + mockedHtmlFormEntryUtil.close(); + } + + // --- Constructor validation --- + + @Test + public void constructor_shouldThrowWhenConceptIdParamIsMissing() { + assertThrows(BadFormDesignException.class, () -> new AppointmentServiceObsElement(context, new HashMap<>())); + } + + @Test + public void constructor_shouldThrowWhenConceptCannotBeFound() { + mockedHtmlFormEntryUtil.when(() -> HtmlFormEntryUtil.getConcept("unknown")).thenReturn(null); + assertThrows(BadFormDesignException.class, + () -> new AppointmentServiceObsElement(context, params("conceptId", "unknown"))); + } + + // --- Speciality filtering --- + + @Test + public void constructor_shouldIncludeAllServicesWhenNoSpecialityFilterGiven() throws Exception { + when(appointmentServiceDefinitionService.getAllAppointmentServices(false)).thenReturn( + Arrays.asList(makeService("Ortho", 1, "uuid-ortho", makeSpeciality("Orthopaedics", "spec-uuid-ortho")), + makeService("Cardiology", 2, "uuid-cardio", makeSpeciality("Cardiology", "spec-uuid-cardio")))); + + AppointmentServiceObsElement element = new AppointmentServiceObsElement(context, params("conceptId", CONCEPT_ID)); + stubFieldName(element, "field"); + String html = element.generateHtml(context); + + assertThat(html.contains("uuid-ortho"), is(true)); + assertThat(html.contains("uuid-cardio"), is(true)); + } + + @Test + public void constructor_shouldFilterServicesBySpeciality() throws Exception { + when(appointmentServiceDefinitionService.getAllAppointmentServices(false)).thenReturn( + Arrays.asList(makeService("Ortho", 1, "uuid-ortho", makeSpeciality("Orthopaedics", "spec-uuid-ortho")), + makeService("Cardiology", 2, "uuid-cardio", makeSpeciality("Cardiology", "spec-uuid-cardio")))); + + Map p = params("conceptId", CONCEPT_ID); + p.put("specialities", "Cardiology"); + AppointmentServiceObsElement element = new AppointmentServiceObsElement(context, p); + stubFieldName(element, "field"); + String html = element.generateHtml(context); + + assertThat(html.contains("uuid-cardio"), is(true)); + assertThat(html.contains("uuid-ortho"), is(false)); + } + + @Test + public void constructor_shouldMatchSpecialityFilterCaseInsensitively() throws Exception { + when(appointmentServiceDefinitionService.getAllAppointmentServices(false)) + .thenReturn(Collections.singletonList(makeService("Cardiology", 2, "uuid-cardio", makeSpeciality("Cardiology", "spec-uuid-cardio")))); + + Map p = params("conceptId", CONCEPT_ID); + p.put("specialities", "CARDIOLOGY"); + AppointmentServiceObsElement element = new AppointmentServiceObsElement(context, p); + stubFieldName(element, "field"); + String html = element.generateHtml(context); + + assertThat(html.contains("uuid-cardio"), is(true)); + } + + @Test + public void constructor_shouldFilterServicesBySpecialityUuid() throws Exception { + when(appointmentServiceDefinitionService.getAllAppointmentServices(false)).thenReturn( + Arrays.asList(makeService("Ortho", 1, "uuid-ortho", makeSpeciality("Orthopaedics", "spec-uuid-ortho")), + makeService("Cardiology", 2, "uuid-cardio", makeSpeciality("Cardiology", "spec-uuid-cardio")))); + + Map p = params("conceptId", CONCEPT_ID); + p.put("specialities", "spec-uuid-cardio"); + AppointmentServiceObsElement element = new AppointmentServiceObsElement(context, p); + stubFieldName(element, "field"); + String html = element.generateHtml(context); + + assertThat(html.contains("uuid-cardio"), is(true)); + assertThat(html.contains("uuid-ortho"), is(false)); + } + + @Test + public void constructor_shouldExcludeServicesWithNoSpecialityWhenFilterIsSet() throws Exception { + when(appointmentServiceDefinitionService.getAllAppointmentServices(false)) + .thenReturn(Collections.singletonList(makeService("General", 3, "uuid-general", null))); + + Map p = params("conceptId", CONCEPT_ID); + p.put("specialities", "Cardiology"); + AppointmentServiceObsElement element = new AppointmentServiceObsElement(context, p); + stubFieldName(element, "field"); + String html = element.generateHtml(context); + + assertThat(html.contains("uuid-general"), is(false)); + } + + // --- handleSubmission: ENTER mode --- + + @Test + public void handleSubmission_shouldCreateObsInEnterMode() throws Exception { + AppointmentServiceObsElement element = new AppointmentServiceObsElement(context, params("conceptId", CONCEPT_ID)); + HttpServletRequest request = requestWithService(SERVICE_UUID, element); + + element.handleSubmission(session, request); + + List obsToCreate = actions.getObsToCreate(); + assertEquals(1, obsToCreate.size()); + assertEquals(concept, obsToCreate.get(0).getConcept()); + assertEquals(SERVICE_UUID, obsToCreate.get(0).getValueText()); + } + + @Test + public void handleSubmission_shouldNotCreateObsWhenValueIsEmpty() throws Exception { + AppointmentServiceObsElement element = new AppointmentServiceObsElement(context, params("conceptId", CONCEPT_ID)); + HttpServletRequest request = requestWithService("", element); + + element.handleSubmission(session, request); + + assertEquals(0, actions.getObsToCreate().size()); + } + + // --- handleSubmission: EDIT mode --- + + @Test + public void handleSubmission_shouldModifyExistingObsInEditMode() throws Exception { + Obs existingObs = new Obs(); + existingObs.setConcept(concept); + existingObs.setValueText("old-service-uuid"); + when(context.removeExistingObs(concept, (Concept) null)).thenReturn(existingObs); + when(context.getMode()).thenReturn(Mode.EDIT); + + AppointmentServiceObsElement element = new AppointmentServiceObsElement(context, params("conceptId", CONCEPT_ID)); + HttpServletRequest request = requestWithService(SERVICE_UUID, element); + + element.handleSubmission(session, request); + + // modifyObs voids the old obs and queues a new one + assertEquals(1, actions.getObsToVoid().size()); + assertEquals(existingObs, actions.getObsToVoid().get(0)); + assertEquals(1, actions.getObsToCreate().size()); + assertEquals(SERVICE_UUID, actions.getObsToCreate().get(0).getValueText()); + } + + @Test + public void handleSubmission_shouldVoidExistingObsWhenValueIsClearedInEditMode() throws Exception { + Obs existingObs = new Obs(); + existingObs.setConcept(concept); + existingObs.setValueText("old-service-uuid"); + when(context.removeExistingObs(concept, (Concept) null)).thenReturn(existingObs); + when(context.getMode()).thenReturn(Mode.EDIT); + + AppointmentServiceObsElement element = new AppointmentServiceObsElement(context, params("conceptId", CONCEPT_ID)); + HttpServletRequest request = requestWithService("", element); + + element.handleSubmission(session, request); + + assertEquals(1, actions.getObsToVoid().size()); + assertEquals(existingObs, actions.getObsToVoid().get(0)); + assertEquals(0, actions.getObsToCreate().size()); + } + + // --- VIEW mode display --- + + @Test + public void generateHtml_shouldDisplayServiceNameInViewModeWhenInsideObsGroup() throws Exception { + Obs existingObs = new Obs(); + existingObs.setConcept(concept); + existingObs.setValueText(SERVICE_UUID); + when(context.getCurrentObsGroupConcepts()).thenReturn(Collections.singletonList(new Concept())); + when(context.getObsFromCurrentGroup(concept, (Concept) null)).thenReturn(existingObs); + when(context.getMode()).thenReturn(Mode.VIEW); + + AppointmentServiceObsElement element = new AppointmentServiceObsElement(context, params("conceptId", CONCEPT_ID)); + + String html = element.generateHtml(context); + + assertThat(html, is("Cardiology")); + } + + @Test + public void generateHtml_shouldDisplayServiceNameInViewMode() throws Exception { + Obs existingObs = new Obs(); + existingObs.setConcept(concept); + existingObs.setValueText(SERVICE_UUID); + when(context.removeExistingObs(concept, (Concept) null)).thenReturn(existingObs); + when(context.getMode()).thenReturn(Mode.VIEW); + + AppointmentServiceObsElement element = new AppointmentServiceObsElement(context, params("conceptId", CONCEPT_ID)); + + String html = element.generateHtml(context); + + assertThat(html, is("Cardiology")); + } + + // --- handleSubmission: VIEW mode --- + + @Test + public void handleSubmission_shouldDoNothingInViewMode() throws Exception { + when(context.getMode()).thenReturn(Mode.VIEW); + + AppointmentServiceObsElement element = new AppointmentServiceObsElement(context, params("conceptId", CONCEPT_ID)); + HttpServletRequest request = requestWithService(SERVICE_UUID, element); + + element.handleSubmission(session, request); + + assertEquals(0, actions.getObsToCreate().size()); + } + + // --- controlId / form path stamping --- + + @Test + public void handleSubmission_shouldStampObsWithControlFormPathWhenControlIdIsSet() throws Exception { + Form form = new Form(); + form.setName("MyForm"); + form.setVersion("1.0"); + when(session.getForm()).thenReturn(form); + doCallRealMethod().when(session).generateControlFormPath(anyString(), anyInt()); + + Map p = params("conceptId", CONCEPT_ID); + p.put("controlId", "appt_service_obs"); + AppointmentServiceObsElement element = new AppointmentServiceObsElement(context, p); + HttpServletRequest request = requestWithService(SERVICE_UUID, element); + + element.handleSubmission(session, request); + + List obsToCreate = actions.getObsToCreate(); + assertEquals(1, obsToCreate.size()); + assertEquals("HtmlFormEntry", obsToCreate.get(0).getFormFieldNamespace()); + assertEquals("MyForm.1.0/appt_service_obs-0", obsToCreate.get(0).getFormFieldPath()); + } + + @Test + public void handleSubmission_shouldNotStampObsWithFormPathWhenNoControlId() throws Exception { + AppointmentServiceObsElement element = new AppointmentServiceObsElement(context, params("conceptId", CONCEPT_ID)); + HttpServletRequest request = requestWithService(SERVICE_UUID, element); + + element.handleSubmission(session, request); + + List obsToCreate = actions.getObsToCreate(); + assertEquals(1, obsToCreate.size()); + assertNull(obsToCreate.get(0).getFormFieldNamespace()); + assertNull(obsToCreate.get(0).getFormFieldPath()); + } + + @Test + public void constructor_shouldLookUpExistingObsByControlIdWhenSet() throws Exception { + Obs existingObs = new Obs(); + existingObs.setConcept(concept); + existingObs.setValueText(SERVICE_UUID); + when(context.getObsFromExistingObs(concept, "appt_service_obs")).thenReturn(existingObs); + + Map p = params("conceptId", CONCEPT_ID); + p.put("controlId", "appt_service_obs"); + AppointmentServiceObsElement element = new AppointmentServiceObsElement(context, p); + stubFieldName(element, "field"); + String html = element.generateHtml(context); + + // the existing obs UUID should be pre-selected + assertThat(html.contains("selected"), is(true)); + } + + // --- Helpers --- + + private Map params(String key, String value) { + Map map = new HashMap<>(); + map.put(key, value); + return map; + } + + private void stubFieldName(AppointmentServiceObsElement element, String fieldName) { + when(context.getFieldName(element.serviceWidget)).thenReturn(fieldName); + } + + private MockHttpServletRequest requestWithService(String uuid, AppointmentServiceObsElement element) { + String fieldName = "service-field"; + stubFieldName(element, fieldName); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addParameter(fieldName, uuid); + return request; + } + + private AppointmentServiceDefinition makeService(String name, Integer id, String uuid, Speciality speciality) { + AppointmentServiceDefinition svc = new AppointmentServiceDefinition(); + svc.setName(name); + svc.setId(id); + svc.setUuid(uuid); + svc.setSpeciality(speciality); + return svc; + } + + private Speciality makeSpeciality(String name, String uuid) { + Speciality speciality = new Speciality(); + speciality.setName(name); + speciality.setUuid(uuid); + return speciality; + } +}