Skip to content

Commit 8cc5ff7

Browse files
authored
HTML-884: Move view-mode html generation into server-side for order widget (#336)
1 parent 586acdc commit 8cc5ff7

3 files changed

Lines changed: 290 additions & 11 deletions

File tree

api-tests/src/test/java/org/openmrs/module/htmlformentry/widget/OrderWidgetTest.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package org.openmrs.module.htmlformentry.widget;
22

33
import static org.hamcrest.MatcherAssert.assertThat;
4+
import static org.hamcrest.Matchers.containsString;
45
import static org.hamcrest.Matchers.is;
6+
import static org.hamcrest.Matchers.not;
57

68
import java.util.ArrayList;
79
import java.util.List;
@@ -56,6 +58,28 @@ public void shouldRenderTemplateWithAllWidgets() {
5658
}
5759
}
5860

61+
@Test
62+
public void shouldRenderStaticHtmlForExistingOrdersInViewMode() {
63+
FormTester formTester = FormTester.buildForm("orderTestForm.xml");
64+
FormSessionTester fst = formTester.openExistingToView(3);
65+
String html = fst.getHtmlToDisplay();
66+
// Orders should be pre-rendered as static HTML — no script tag needed
67+
assertThat(html, not(containsString("orderWidget.initialize")));
68+
// Section structure must mirror what renderOrdersForRevision() produces so CSS rules apply correctly
69+
assertThat(html, containsString("orderwidget-existing-order-section"));
70+
assertThat(html, containsString("orderwidget-new-order-section"));
71+
assertThat(html, containsString("orderwidget-orderable-section"));
72+
assertThat(html, containsString("orderwidget-history-section"));
73+
// Active/inactive and encounter-context CSS classes should be applied
74+
// Encounter 3 date is 2008-08-01, but orders' dateActivated are after that date,
75+
// so isOrderActive() correctly classifies them as inactive
76+
assertThat(html, containsString("order-view-inactive"));
77+
assertThat(html, containsString("order-view-current-encounter"));
78+
// Configured fields and their values should be rendered
79+
assertThat(html, containsString("order-field-widget order-drug"));
80+
assertThat(html, containsString("Drug 3"));
81+
}
82+
5983
@Test
6084
public void shouldRenderTemplateWithWidgetsForTestOrder() {
6185
FormTester formTester = FormTester.buildForm("orderLabTestForm.xml");

api/src/main/java/org/openmrs/module/htmlformentry/widget/OrderWidget.java

Lines changed: 263 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@
2828
import javax.servlet.http.HttpServletRequest;
2929
import java.text.SimpleDateFormat;
3030
import java.util.ArrayList;
31+
import java.util.Arrays;
3132
import java.util.Date;
3233
import java.util.HashMap;
34+
import java.util.HashSet;
3335
import java.util.LinkedHashMap;
3436
import java.util.List;
3537
import java.util.Map;
@@ -380,7 +382,14 @@ public String generateHtml(FormEntryContext context) {
380382
writer.println("<div id=\"" + fieldName + "\" class=\"orderwidget-element\">");
381383

382384
// Add a section that will contain the selected orders
383-
writer.println("<div id=\"" + fieldName + "_orders\" class=\"orderwidget-order-section\"></div>");
385+
// In VIEW mode, pre-render orders as static HTML so the widget is usable without JavaScript
386+
if (context.getMode() == FormEntryContext.Mode.VIEW) {
387+
writer.println("<div id=\"" + fieldName + "_orders\" class=\"orderwidget-order-section\">");
388+
writer.println(generateStaticOrdersHtml(context));
389+
writer.println("</div>");
390+
} else {
391+
writer.println("<div id=\"" + fieldName + "_orders\" class=\"orderwidget-order-section\"></div>");
392+
}
384393

385394
// Add a section that can contain a selector
386395
writer.println("<div id=\"" + fieldName + "_header\" class=\"orderwidget-selector-section\"></div>");
@@ -444,28 +453,269 @@ public String generateHtml(FormEntryContext context) {
444453
writer.println(StringUtils.isBlank(viewTemplateContent) ? defaultViewContent : viewTemplateContent);
445454
writer.println("</div>");
446455

447-
// Add javascript function to initialize widget as appropriate
448-
String defaultLoadFn = "orderWidget.initialize";
449-
String onLoadFn = widgetConfig.getAttributes().getOrDefault("onLoadFunction", defaultLoadFn);
450-
writer.println("<script type=\"text/javascript\">");
451-
writer.println("jQuery(function() { " + onLoadFn + "(");
452-
writer.println(constructJavascriptConfig(context).toJson());
453-
writer.println(")});");
454-
writer.println("</script>");
456+
// In ENTER/EDIT modes, initialize the widget via JavaScript
457+
// VIEW mode is pre-rendered as static HTML above and does not require JavaScript
458+
if (context.getMode() != FormEntryContext.Mode.VIEW) {
459+
String defaultLoadFn = "orderWidget.initialize";
460+
String onLoadFn = widgetConfig.getAttributes().getOrDefault("onLoadFunction", defaultLoadFn);
461+
writer.println("<script type=\"text/javascript\">");
462+
writer.println("jQuery(function() { " + onLoadFn + "(");
463+
writer.println(constructJavascriptConfig(context).toJson());
464+
writer.println(")});");
465+
writer.println("</script>");
466+
}
455467

456468
writer.println("</div>");
457469

458470
return writer.getContent();
459471
}
460472

473+
/**
474+
* Generates static HTML for all existing orders in VIEW mode, replicating the section structure
475+
* that orderWidget.js renderOrdersForRevision() produces so that CSS rules continue to apply correctly.
476+
* Only orders from the current encounter are shown (mirrors shouldRenderOrder() in VIEW mode).
477+
*/
478+
protected String generateStaticOrdersHtml(FormEntryContext context) {
479+
StringBuilder html = new StringBuilder();
480+
if (initialValue == null || initialValue.isEmpty()) {
481+
return html.toString();
482+
}
483+
Date encDate = context.getExistingEncounter() != null ? context.getExistingEncounter().getEncounterDatetime() : new Date();
484+
Integer currentEncId = context.getExistingEncounter() != null ? context.getExistingEncounter().getEncounterId() : null;
485+
486+
// Flat map of all known orders by id, for previous-order lookup
487+
Map<Integer, Order> allOrdersById = new LinkedHashMap<>();
488+
for (List<Order> orders : initialValue.values()) {
489+
for (Order o : orders) {
490+
allOrdersById.put(o.getOrderId(), o);
491+
}
492+
}
493+
494+
// In VIEW mode, shouldRenderOrder() only shows orders from the current encounter.
495+
// Split into existing (REVISE/RENEW/DISCONTINUE) vs new (NEW) — mirrors renderOrdersForRevision().
496+
List<Order> existingOrders = new ArrayList<>();
497+
List<Order> newOrders = new ArrayList<>();
498+
for (Concept c : widgetConfig.getConceptsAndDrugsConfigured().keySet()) {
499+
List<Order> conceptOrders = getInitialValueForConcept(c);
500+
if (conceptOrders == null) {
501+
continue;
502+
}
503+
for (Order order : conceptOrders) {
504+
boolean inCurrentEncounter = currentEncId != null && currentEncId.equals(order.getEncounter().getEncounterId());
505+
if (!inCurrentEncounter) {
506+
continue;
507+
}
508+
if (order.getAction() == Order.Action.NEW) {
509+
newOrders.add(order);
510+
} else {
511+
existingOrders.add(order);
512+
}
513+
}
514+
}
515+
516+
appendOrderSection(html, "orderwidget-existing-order-section",
517+
translate("htmlformentry.orders.existingOrdersViewTitle"),
518+
translate("htmlformentry.orders.noOrders"),
519+
existingOrders, allOrdersById, encDate, currentEncId, context);
520+
521+
appendOrderSection(html, "orderwidget-new-order-section",
522+
translate("htmlformentry.orders.newOrdersTitle"),
523+
translate("htmlformentry.orders.noOrders"),
524+
newOrders, allOrdersById, encDate, currentEncId, context);
525+
526+
return html.toString();
527+
}
528+
529+
private void appendOrderSection(StringBuilder html, String sectionClass, String title, String noOrdersText,
530+
List<Order> orders, Map<Integer, Order> allOrdersById, Date encDate, Integer currentEncId,
531+
FormEntryContext context) {
532+
String sectionHidden = orders.isEmpty() ? " style=\"display:none;\"" : "";
533+
String noOrdersHidden = orders.isEmpty() ? "" : " style=\"display:none;\"";
534+
html.append("<div class=\"").append(sectionClass).append("\"").append(sectionHidden).append(">");
535+
html.append("<div class=\"orderwidget-section-header\">").append(title).append("</div>");
536+
html.append("<div class=\"orderwidget-section-no-orders\"").append(noOrdersHidden).append(">").append(noOrdersText).append("</div>");
537+
for (Order order : orders) {
538+
html.append("<div class=\"orderwidget-orderable-section\">");
539+
html.append("<div class=\"orderwidget-history-section\">");
540+
// Mirror shouldRenderPreviousOrder() + the check that the previous is from a different encounter
541+
if (order.getPreviousOrder() != null && order.getAction() != Order.Action.NEW) {
542+
Integer prevId = order.getPreviousOrder().getOrderId();
543+
Order prevOrder = allOrdersById.getOrDefault(prevId, order.getPreviousOrder());
544+
boolean prevInCurrentEncounter = currentEncId != null && currentEncId.equals(prevOrder.getEncounter().getEncounterId());
545+
if (!prevInCurrentEncounter) {
546+
html.append(buildOrderItemHtml(prevOrder, encDate, currentEncId, context));
547+
}
548+
}
549+
html.append(buildOrderItemHtml(order, encDate, currentEncId, context));
550+
html.append("</div>");
551+
html.append("</div>");
552+
}
553+
html.append("</div>");
554+
}
555+
556+
private String buildOrderItemHtml(Order order, Date encDate, Integer currentEncId, FormEntryContext context) {
557+
boolean isActive = isOrderActive(order, encDate);
558+
boolean inCurrentEncounter = currentEncId != null && currentEncId.equals(order.getEncounter().getEncounterId());
559+
String activeClass = isActive ? "order-view-active" : "order-view-inactive";
560+
String encounterClass = inCurrentEncounter ? "order-view-current-encounter" : "order-view-different-encounter";
561+
StringBuilder html = new StringBuilder();
562+
html.append("<div class=\"orderwidget-order-history-item ").append(activeClass).append(" ").append(encounterClass).append("\">");
563+
Map<String, String> displayValues = getOrderPropertyDisplayValues(order);
564+
Set<String> hiddenProperties = getHiddenPropertiesForAction(order.getAction(), inCurrentEncounter);
565+
// Mirror the same template-building loop used in generateHtml() for _view_template,
566+
// so layout, field order, and custom templates stay consistent with ENTER/EDIT modes.
567+
String templateContent = widgetConfig.getTemplateContent();
568+
StringBuilder defaultContent = new StringBuilder();
569+
for (String property : widgets.keySet()) {
570+
Map<String, String> attrs = widgetConfig.getAttributes(property);
571+
if (attrs == null) {
572+
continue;
573+
}
574+
String display = displayValues.getOrDefault(property, "");
575+
boolean hidden = hiddenProperties.contains(property) || StringUtils.isBlank(display);
576+
String fieldHtml = generateHtmlForWidget(property, null, attrs, context, display, hidden);
577+
if (StringUtils.isBlank(templateContent)) {
578+
defaultContent.append(fieldHtml);
579+
} else {
580+
templateContent = templateContent.replace(attrs.toString(), fieldHtml);
581+
}
582+
}
583+
html.append(StringUtils.isBlank(templateContent) ? defaultContent : templateContent);
584+
html.append("</div>");
585+
return html.toString();
586+
}
587+
588+
/**
589+
* @return a map of property name to display string for all properties of the given order
590+
*/
591+
protected Map<String, String> getOrderPropertyDisplayValues(Order order) {
592+
Map<String, String> values = new LinkedHashMap<>();
593+
values.put("action", getLabelForProperty("action", order.getAction()));
594+
values.put("concept", getLabelForProperty("concept", order.getConcept()));
595+
values.put("careSetting", getLabelForProperty("careSetting", order.getCareSetting()));
596+
values.put("instructions", getLabelForProperty("instructions", order.getInstructions()));
597+
values.put("urgency", getLabelForProperty("urgency", order.getUrgency()));
598+
values.put("dateActivated", getLabelForProperty("dateActivated", order.getDateActivated()));
599+
values.put("scheduledDate", getLabelForProperty("scheduledDate", order.getScheduledDate()));
600+
values.put("effectiveStartDate", getLabelForProperty("effectiveStartDate", order.getEffectiveStartDate()));
601+
values.put("autoExpireDate", getLabelForProperty("autoExpireDate", order.getAutoExpireDate()));
602+
values.put("dateStopped", getLabelForProperty("dateStopped", order.getDateStopped()));
603+
values.put("effectiveStopDate", getLabelForProperty("effectiveStopDate", order.getEffectiveStopDate()));
604+
if (order instanceof DrugOrder) {
605+
DrugOrder d = (DrugOrder) order;
606+
values.put("drug", getLabelForProperty("drug", d.getDrug()));
607+
values.put("drugNonCoded", getLabelForProperty("drugNonCoded", d.getDrugNonCoded()));
608+
values.put("dosingType", getLabelForProperty("dosingType", d.getDosingType()));
609+
values.put("dosingInstructions", getLabelForProperty("dosingInstructions", d.getDosingInstructions()));
610+
values.put("dose", getLabelForProperty("dose", d.getDose()));
611+
values.put("doseUnits", getLabelForProperty("doseUnits", d.getDoseUnits()));
612+
values.put("route", getLabelForProperty("route", d.getRoute()));
613+
values.put("frequency", getLabelForProperty("frequency", d.getFrequency()));
614+
values.put("asNeeded", getLabelForProperty("asNeeded", d.getAsNeeded()));
615+
values.put("duration", getLabelForProperty("duration", d.getDuration()));
616+
values.put("durationUnits", getLabelForProperty("durationUnits", d.getDurationUnits()));
617+
values.put("quantity", getLabelForProperty("quantity", d.getQuantity()));
618+
values.put("quantityUnits", getLabelForProperty("quantityUnits", d.getQuantityUnits()));
619+
values.put("numRefills", getLabelForProperty("numRefills", d.getNumRefills()));
620+
} else if (order instanceof ServiceOrder) {
621+
ServiceOrder s = (ServiceOrder) order;
622+
values.put("specimenSource", getLabelForProperty("specimenSource", s.getSpecimenSource()));
623+
values.put("laterality", getLabelForProperty("laterality", s.getLaterality()));
624+
values.put("clinicalHistory", getLabelForProperty("clinicalHistory", s.getClinicalHistory()));
625+
values.put("frequency", getLabelForProperty("frequency", s.getFrequency()));
626+
values.put("numberOfRepeats", getLabelForProperty("numberOfRepeats", s.getNumberOfRepeats()));
627+
values.put("location", getLabelForProperty("location", s.getLocation()));
628+
}
629+
if (order.getAction() == Order.Action.DISCONTINUE) {
630+
values.put("discontinueReason", getLabelForProperty("discontinueReason", order.getOrderReason()));
631+
values.put("discontinueReasonNonCoded", getLabelForProperty("discontinueReasonNonCoded", order.getOrderReasonNonCoded()));
632+
} else {
633+
values.put("orderReason", getLabelForProperty("orderReason", order.getOrderReason()));
634+
values.put("orderReasonNonCoded", getLabelForProperty("orderReasonNonCoded", order.getOrderReasonNonCoded()));
635+
}
636+
return values;
637+
}
638+
639+
/**
640+
* @return the set of property names that should be hidden for the given action type,
641+
* mirroring the visibility logic in orderWidget.js formatOrder()
642+
*/
643+
protected Set<String> getHiddenPropertiesForAction(Order.Action action, boolean inCurrentEncounter) {
644+
List<String> orderableProperties = Arrays.asList("careSetting", "concept", "drug", "drugNonCoded");
645+
List<String> dosingProperties = Arrays.asList("dosingType", "dose", "doseUnits", "route", "frequency",
646+
"asNeeded", "asNeededCondition", "instructions", "dosingInstructions");
647+
List<String> dispensingProperties = Arrays.asList("quantity", "quantityUnits", "numRefills");
648+
List<String> discontinueProperties = Arrays.asList("discontinueReason", "discontinueReasonNonCoded");
649+
Set<String> hidden = new HashSet<>();
650+
if (action == Order.Action.NEW) {
651+
hidden.addAll(discontinueProperties);
652+
hidden.add("action");
653+
} else if (action == Order.Action.REVISE) {
654+
hidden.addAll(discontinueProperties);
655+
if (inCurrentEncounter) {
656+
hidden.addAll(orderableProperties);
657+
} else {
658+
hidden.add("action");
659+
}
660+
} else if (action == Order.Action.RENEW) {
661+
hidden.addAll(dosingProperties);
662+
hidden.addAll(discontinueProperties);
663+
if (inCurrentEncounter) {
664+
hidden.addAll(orderableProperties);
665+
} else {
666+
hidden.add("action");
667+
}
668+
} else if (action == Order.Action.DISCONTINUE) {
669+
hidden.addAll(dosingProperties);
670+
hidden.addAll(dispensingProperties);
671+
hidden.add("orderReason");
672+
hidden.add("orderReasonNonCoded");
673+
hidden.add("duration");
674+
hidden.add("durationUnits");
675+
if (inCurrentEncounter) {
676+
hidden.addAll(orderableProperties);
677+
} else {
678+
hidden.add("action");
679+
}
680+
}
681+
return hidden;
682+
}
683+
684+
/**
685+
* @return true if the order is currently active as of the given date
686+
*/
687+
protected boolean isOrderActive(Order order, Date asOfDate) {
688+
if (order.getDateActivated() != null && order.getDateActivated().after(asOfDate)) {
689+
return false;
690+
}
691+
Date effectiveStopDate = order.getEffectiveStopDate();
692+
if (effectiveStopDate != null && !effectiveStopDate.after(asOfDate)) {
693+
return false;
694+
}
695+
return true;
696+
}
697+
461698
/**
462699
* @return the html to render for each found property widget
463700
*/
464701
public String generateHtmlForWidget(String property, Widget w, Map<String, String> attrs, FormEntryContext context) {
702+
return generateHtmlForWidget(property, w, attrs, context, null, false);
703+
}
704+
705+
/**
706+
* Renders a single order property field. When displayValue is non-null the field is rendered as
707+
* static text (VIEW mode); otherwise the interactive widget is used (ENTER/EDIT mode).
708+
*/
709+
public String generateHtmlForWidget(String property, Widget w, Map<String, String> attrs, FormEntryContext context,
710+
String displayValue, boolean hidden) {
465711
StringBuilder ret = new StringBuilder();
466712
String labelCode = attrs.getOrDefault(OrderTagHandler.LABEL_ATTRIBUTE, "htmlformentry.orders." + property);
467713
String label = translate(labelCode);
468-
ret.append("<div class=\"order-field order-").append(property).append("\">");
714+
ret.append("<div class=\"order-field order-").append(property);
715+
if (hidden) {
716+
ret.append(" order-field-hidden");
717+
}
718+
ret.append("\">");
469719
ret.append("<div class=\"order-field-label order-").append(property).append("\">");
470720
ret.append(label);
471721
ret.append("</div>");
@@ -474,7 +724,9 @@ public String generateHtmlForWidget(String property, Widget w, Map<String, Strin
474724
ret.append(" order-field-radio-group");
475725
}
476726
ret.append("\">");
477-
if (w != null) {
727+
if (displayValue != null) {
728+
ret.append(displayValue);
729+
} else if (w != null) {
478730
ret.append(w.generateHtml(context));
479731
ErrorWidget ew = context.getErrorWidget(w);
480732
if (ew != null) {

omod/src/main/webapp/resources/orderWidget.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,13 @@
168168

169169
$existingOrderSection.append('<div class="orderwidget-section-header">' + existingOrdersTitle + '</div>');
170170
$existingOrderSection.append('<div class="orderwidget-section-no-orders">' + noOrdersTitle + '</div>');
171+
$existingOrderSection.hide();
171172
$orderSection.append($existingOrderSection);
172173

173174
var $newOrderSection = $('<div class="orderwidget-new-order-section"></div>');
174175
$newOrderSection.append('<div class="orderwidget-section-header">' + newOrdersTitle + '</div>');
175176
$newOrderSection.append('<div class="orderwidget-section-no-orders">' + noOrdersTitle + '</div>');
177+
$newOrderSection.hide();
176178
$orderSection.append($newOrderSection);
177179

178180
config.history.forEach(function(order) {
@@ -189,6 +191,7 @@
189191
}
190192

191193
orderWidget.addOrderSection = function($sectionToAddTo, config) {
194+
$sectionToAddTo.show();
192195
$sectionToAddTo.find(".orderwidget-section-no-orders").hide();
193196
var orderableSectionId = 'orderwidget-orderable-section-' + orderWidget.nextOrderableSectionIndex();
194197
var $orderableSection = $('<div id="' + orderableSectionId + '" class="orderwidget-orderable-section"></div>');

0 commit comments

Comments
 (0)