Warning |
---|
The UI Framework code is being transitioned to a module. Documentation will move to UI Framework. |
(Prerequisite: 2.x UI Framework Step By Step Tutorial for Core Developers)
This guide will take you through the process of writing a Patient fragment, while following all best practices best practices.
A "Patient fragment" is (obviously) a page fragment that displays data about a patient. While such fragments are typically displayed on a patient dashboard, our best practices will also allow these fragments to be used on pages that aggregate data for multiple patients.
...
Step 3: Including our patient fragment in standard pages
In the UI Framework tutorial, you created demonstration page to hold your fragments. Since we are now building real functionality, we want to include our fragment in the real user interface. Since the 2.x application is configurable and customizable, there is no single "patient dashboard" as in OpenMRS 1.x. Adding our fragment to the user interface actually means publishing it in the reference application's library of patient fragments, which are exposed as Extensions. To do this we need to add one line to the org.openmrs.ui2.webapp.extension.CoreExtensionFactory class:
...
Code Block | ||
---|---|---|
| ||
/** * Controller for the PatientIdentifiers fragment. * Displays a table of PatientIdentifiers, and allow the user to add another (via a short popup form), * or delete one (with confirmation) as long as it isn't the last one. */ public class PatientIdentifiersFragmentController { /** * Controller method when the fragment is included on a page */ public void controller(PageModel sharedPageModel, FragmentConfiguration config, FragmentModel model) { model.addAttribute("patient", FragmentUtil.getPatient(sharedPageModel, config)); } /** * Fragment Action for fetching list of active patient identifiers */ public ObjectResultObject getActiveIdentifiers(UiUtils ui, @RequestParam("patientId") Patient patient) { return ObjectResultSimpleObject.buildfromCollection(ui, patient.getActiveIdentifiers(), ui, "patientIdentifierId", "identifierType", "identifier", "location"); } } |
...
In reality, we aren't going to have a "refresh" button. Rather we want our fragment to refresh automatically when told that the specific patient data it displays has been updated. (For a list of standardized messages people have already defined, see the style guide.)
In our patientIdentifiers fragment, we want to redraw ourselves on three different messages:
- (OUR-FRAGMENT-ID).refresh (may be called by generic page decoration)
- "patient/(id).changed" (the generic message that something unspecified about a patient has changed)
- "patient/(id)/identifiers.changed" (the specific message that the patient's identifiers have changed)
The latter two messages promise to include a "patient" javascript object as their message content, which always has a "patientId" property, and may have an "activeIdentifiers" property, but the generic refresh message won't provide us anything.
We add a bit of javascript to listen for these messages:
Code Block | ||
---|---|---|
| ||
<%
def id = config.id ?: ui.randomId("patientIdentifiers")
%>
<script>
function refreshPatientIdentifierTable(divId, patientId) {
jq('#' + divId + '_table > tbody').empty();
jq.getJSON('${ ui.actionLink("getActiveIdentifiers", [returnFormat: "json"]) }', { patientId: patientId },
function(data) {
publish(divId + "_table.show-data", data);
});
}
function refreshPatientIdentifiers${ id }(message, data) {
if (data && data.activeIdentifiers)
publish("${ id }_table.show-data", data.activeIdentifiers);
else
refreshPatientIdentifierTable('${ id }', ${ patient.patientId });
}
subscribe('${ id }.refresh', refreshPatientIdentifiers${ id });
subscribe('patient/${ patient.patientId }.changed', refreshPatientIdentifiers${ id });
subscribe('patient/${ patient.patientId }/identifiers.changed', refreshPatientIdentifiers${ id });
</script>
<div id="${ id }">
<a href="javascript:publish('${ id }.refresh')">div id refresh</a>
<a href="javascript:patientChanged(${ patient.patientId })">patient changed</a>
<a href="javascript:patientChanged(${ patient.patientId }, 'identifiers')">identifiers changed</a>
${ ui.includeFragment("widgets/table", [
id: id + "_table",
columns: [
[ property: "identifierType", heading: ui.message("PatientIdentifier.type") ],
[ property: "identifier", userEntered: true, heading: ui.message("PatientIdentifier.identifier") ],
[ property: "location", heading: ui.message("PatientIdentifier.location") ]
],
rows: patient.activeIdentifiers,
ifNoRowsMessage: ui.message("general.none")
]) }
</div>
|
...
We've also changed our temporary test link to test all three caescases. The first publishes a refresh message, the next two call a utility function defined in openmrs.js which publishes a patient changed message with the correct payload.) So at this point if you reload the page, and click on the three links, you will see the fragment update correctly.
...
Code Block | ||
---|---|---|
| ||
<% def id = config.id ?: ui.randomId("patientIdentifiers") %> <script> function refreshPatientIdentifierTable(divId, patientId) { jq('#' + divId + '_table > tbody').empty(); jq.getJSON('${ ui.actionLink("getActiveIdentifiers", [returnFormat: "json"]) }', { patientId: patientId }, function(data) { publish(divId + "_table.show-data", data); }); } function refreshPatientIdentifiers${ id }(message, data) { if (data && data.activeIdentifiers) publish("${ id }_table.show-data", data.activeIdentifiers); else refreshPatientIdentifierTable('${ id }', ${ patient.patientId }); } subscribe('${ id }.refresh', refreshPatientIdentifiers${ id }); subscribe('patient/${ patient.patientId }.changed', refreshPatientIdentifiers${ id }); subscribe('patient/${ patient.patientId }/identifiers.changed', refreshPatientIdentifiers${ id }); </script> <div id="${ id }"> ${ ui.includeFragment("widgets/table", [ id: id + "_table", columns: [ [ property: "identifierType", heading: ui.message("PatientIdentifier.identifierType") ], [ property: "identifier", userEntered: true, heading: ui.message("PatientIdentifier.identifier") ], [ property: "location", heading: ui.message("PatientIdentifier.location") ] ], rows: patient.activeIdentifiers, ifNoRowsMessage: ui.message("general.none") ]) } ${ ui.includeFragment("widgets/popupForm", [ id: id + "_add", buttonLabel: ui.message("general.add"), popupTitle: ui.message("patientIdentifier.add"), fragment: "patientIdentifiers", action: "addIdentifier", submitLabel: ui.message("general.save"), cancelLabel: ui.message("general.cancel"), fields: [ [ hiddenInputName: "patientId", value: patient.patientId ], [ label: ui.message("PatientIdentifier.identifierType"), formFieldName: "identifierType", class: org.openmrs.PatientIdentifierType ], [ label: ui.message("PatientIdentifier.identifier"), formFieldName: "identifier", class: java.lang.String ], [ label: ui.message("PatientIdentifier.location"), formFieldName: "location", class: org.openmrs.Location ] ] ]) } </div> |
...
Code Block | ||
---|---|---|
| ||
<% def id = config.id ?: ui.randomId("patientIdentifiers") %> <script> function refreshPatientIdentifierTable(divId, patientId) { jq('#' + divId + '_table > tbody').empty(); jq.getJSON('${ ui.actionLink("getActiveIdentifiers", [returnFormat: "json"]) }', { patientId: patientId }, function(data) { publish(divId + "_table.show-data", data); }); } function refreshPatientIdentifiers${ id }(message, data) { if (data && data.activeIdentifiers) publish("${ id }_table.show-data", data.activeIdentifiers); else refreshPatientIdentifierTable('${ id }', ${ patient.patientId }); } subscribe('${ id }.refresh', refreshPatientIdentifiers${ id }); subscribe('patient/${ patient.patientId }.changed', refreshPatientIdentifiers${ id }); subscribe('patient/${ patient.patientId }/identifiers.changed', refreshPatientIdentifiers${ id }); subscribe('${ id }_table.delete-button-clicked', function(message, data) { if (prettyConfirmopenmrsConfirm('${ ui.message("general.confirm") }')) { jq.post('${ ui.actionLink("deleteIdentifier") }', { returnFormat: 'json', patientIdentifierId: data }, function(data) { location.reload(true); }) .error(function() { flashErrornotifyError("Programmer error: delete identifier failed"); }) } }); </script> <div id="${ id }"> ${ ui.includeFragment("widgets/table", [ id: id + "_table", columns: [ [ property: "identifierType", heading: ui.message("PatientIdentifier.identifierType") ], [ property: "identifier", userEntered: true, heading: ui.message("PatientIdentifier.identifier") ], [ property: "location", heading: ui.message("PatientIdentifier.location") ], [ actions: [ [ action: "event", icon: "delete24.png", tooltip: ui.message("PatientIdentifier.delete"), event: id + ".delete-button-clicked", property: "patientIdentifierId" ] ] ] ], rows: patient.activeIdentifiers, ifNoRowsMessage: ui.message("general.none") ]) } ${ ui.includeFragment("widgets/popupForm", [ id: id + "_add", buttonLabel: ui.message("general.add"), popupTitle: ui.message("PatientIdentifier.add"), fragment: "patientIdentifiers", action: "addIdentifier", submitLabel: ui.message("general.save"), cancelLabel: ui.message("general.cancel"), fields: [ [ hiddenInputName: "patientId", value: patient.patientId ], [ label: ui.message("PatientIdentifier.identifierType"), formFieldName: "identifierType", class: org.openmrs.PatientIdentifierType ], [ label: ui.message("PatientIdentifier.identifier"), formFieldName: "identifier", class: java.lang.String ], [ label: ui.message("PatientIdentifier.location"), formFieldName: "location", class: org.openmrs.Location ] ] ]) } </div> |
(Note: the details of this may change when the "table" widget is refactored - TRUNK-2060)We've added two things: an action as one of the table columns
(Note that I had to change 'event: id + ".delete-button-clicked"' to 'event: id + "_table.delete-button-clicked"' to get this to work - Mark)
We've added two things: an action as one of the table columns, and an event subscription that listens for the button being clicked. (It might seem more natural if the action in the table row called a plain javascript function, but the event mechanism makes it easier for the table widget to handle the same action on the inital page load, and when the table is populated via ajax.)
...
Changing the add and remove actions so that the work via AJAX is actually quite easy, since we can let the UI framework do most of the hard work for us. The first things we need to do is change our addIdentifier and deleteIdentifier fragment actions to have them return ObjectResultsObjects. All ajaxified patient fragments are expected to publish a patient changed event, with a standard simplified javascript representation of the Patient object . (This will allow multiple page fragments to redraw themselves when a piece of patient data is changed, without needing to make their own ajax calls to the server, improving OpenMRS's functionality over slow network connections.) We in a standard format. We are going to use a standard utility method to produce the json-ready patient object. (As you build other patient fragments, you will likely need to tweak that standardized method by adding more propertiesThis utility method may not contain all the properties you want. You may want to tweak the method to return a patient object with more properties, but don't overdo it. You may also construct a result with the utility methods in the SimpleObject class.)
Code Block | ||
---|---|---|
| ||
/** * Controller for the PatientIdentifiers fragment. * Displays a table of PatientIdentifiers, and allow the user to add another (via a short popup form), * or delete one (with confirmation) as long as it isn't the last one. */ public class PatientIdentifiersFragmentController { /** * Controller method when the fragment is included on a page */ public void controller(PageModel sharedPageModel, FragmentConfiguration config, FragmentModel model) { model.addAttribute("patient", FragmentUtil.getPatient(sharedPageModel, config)); } /** * Fragment Action for fetching list of active patient identifiers */ public ObjectResultObject getActiveIdentifiers(UiUtils ui, @RequestParam("patientId") Patient patient) { return ObjectResultSimpleObject.buildfromCollection(ui, patient.getActiveIdentifiers(), ui, "patientIdentifierId", "identifierType", "identifier", "location"); } /** * Fragment Action for adding a new identifier */ public FragmentActionResultObject addIdentifier(UiUtils ui, @RequestParam("patientId") Patient patient, @RequestParam("identifierType") PatientIdentifierType idType, @RequestParam("identifier") String identifier, @RequestParam("location") Location location) { patient.addIdentifier(new PatientIdentifier(identifier, idType, location)); Context.getPatientService().savePatient(patient); return FragmentUtil.standardPatientObject(ui, patient); } /** * Fragment Action for deleting an existing identifier */ public FragmentActionResultObject deleteIdentifier(UiUtils ui, @RequestParam("patientIdentifierId") Integer id) { PatientService ps = Context.getPatientService(); PatientIdentifier pid = ps.getPatientIdentifier(id); ps.voidPatientIdentifier(pid, "user interface"); return FragmentUtil.standardPatientObject(ui, pid.getPatient()); } } |
The only change we've made is to return a ObjectResult an Object representing a standard patient, instead of a SuccessResult.
...
Code Block | ||
---|---|---|
| ||
<% def id = config.id ?: ui.randomId("patientIdentifiers") %> <script> function refreshPatientIdentifierTable(divId, patientId) { jq('#' + divId + '_table > tbody').empty(); jq.getJSON('${ ui.actionLink("getActiveIdentifiers", [returnFormat: "json"]) }', { patientId: patientId }, function(data) { publish(divId + "_table.show-data", data); }); } function refreshPatientIdentifiers${ id }(message, data) { if (data && data.activeIdentifiers) publish("${ id }_table.show-data", data.activeIdentifiers); else refreshPatientIdentifierTable('${ id }', ${ patient.patientId }); } subscribe('${ id }.refresh', refreshPatientIdentifiers${ id }); subscribe('patient/${ patient.patientId }.changed', refreshPatientIdentifiers${ id }); subscribe('patient/${ patient.patientId }/identifiers.changed', refreshPatientIdentifiers${ id }); subscribe('${ id }.delete-button-clicked', function(message, data) { if (openmrsConfirm('${ ui.message("general.confirm") }')) { jq.post('${ ui.actionLink("deleteIdentifier") }', { returnFormat: 'json', patientIdentifierId: data }, function(data) { flashSuccessnotifySuccess('${ ui.escapeJs(ui.message("PatientIdentifier.deleted")) }'); publish('patient/${ patient.patientId }/identifiers.changed', data); }, 'json') .error(function() { flashErrornotifyError("Programmer error: delete identifier failed"); }) } }); </script> <div id="${ id }"> ${ ui.includeFragment("widgets/table", [ id: id + "_table", columns: [ [ property: "identifierType", heading: ui.message("PatientIdentifier.identifierType") ], [ property: "identifier", userEntered: true, heading: ui.message("PatientIdentifier.identifier") ], [ property: "location", heading: ui.message("PatientIdentifier.location") ], [ actions: [ [ action: "event", icon: "delete24.png", tooltip: ui.message("PatientIdentifier.delete"), event: id + ".delete-button-clicked", property: "patientIdentifierId" ] ] ] ], rows: patient.activeIdentifiers, ifNoRowsMessage: ui.message("general.none") ]) } ${ ui.includeFragment("widgets/popupForm", [ id: id + "_add", buttonLabel: ui.message("general.add"), popupTitle: ui.message("PatientIdentifier.add"), fragment: "patientIdentifiers", action: "addIdentifier", submitLabel: ui.message("general.save"), cancelLabel: ui.message("general.cancel"), fields: [ [ hiddenInputName: "patientId", value: patient.patientId ], [ label: ui.message("PatientIdentifier.identifierType"), formFieldName: "identifierType", class: org.openmrs.PatientIdentifierType ], [ label: ui.message("PatientIdentifier.identifier"), formFieldName: "identifier", class: java.lang.String ], [ label: ui.message("PatientIdentifier.location"), formFieldName: "location", class: org.openmrs.Location ] ], successEvent: "patient/" + patient.patientId + "/identifiers.changed" ]) } </div> |
...
In case of any errors while executing a fragment action, you just need to return a FailureResult, and the UI framework will take care of sending it back in the correct way. (If the fragment is called via ajax, an errors object is returned as json or xml. If the fragment is done as a regular form submission, it is returned in a session attribute and displayed at the top of the page.) Typically fragment actions will declare a return type of FragmentActionResultObject, so they may return either a Success/Object result, or a FailureResult.
...
Code Block | ||
---|---|---|
| ||
/** * Fragment Action for deleting an existing identifier */ public FragmentActionResultObject deleteIdentifier(UiUtils ui, @RequestParam("patientIdentifierId") Integer id, @RequestParam(value="reason", defaultValue="user interface") String reason) { PatientService ps = Context.getPatientService(); PatientIdentifier pid = ps.getPatientIdentifier(id); // don't touch it if it's already deleted if (pid.isVoided()) return new FailureResult(ui.message("PatientIdentifier.delete.error.already")); // don't delete the last active identifier if (pid.getPatient().getActiveIdentifiers().size() == 1) { return new FailureResult(ui.message("PatientIdentifier.delete.error.last")); } // otherwise, we go ahead and delete it ps.voidPatientIdentifier(pid, reason); return FragmentUtil.standardPatientObject(ui, pid.getPatient()); } |
...
Code Block | ||
---|---|---|
| ||
subscribe('${ id }.delete-button-clicked', function(message, data) { if (openmrsConfirm('${ ui.message("general.confirm") }')) { jq.post('${ ui.actionLink("deleteIdentifier") }', { returnFormat: 'json', patientIdentifierId: data }, function(data) { flashSuccessnotifySuccess('${ ui.escapeJs(ui.message("PatientIdentifier.deleted")) }'); publish('patient/${ patient.patientId }/identifiers.changed', data); }, 'json') .error(function(xhr) { fragmentActionError(xhr, "Programmer error: delete identifier failed"); }) } }); |
...
Code Block | ||
---|---|---|
| ||
/** * Fragment Action for marking an identifier as preferred */ public FragmentActionResultObject setPreferredIdentifier(UiUtils ui, @RequestParam("patientIdentifierId") PatientIdentifier pid) { PatientService ps = Context.getPatientService(); if (pid.isVoided()) return new FailureResult(ui.message("PatientIdentifier.setPreferred.error.deleted")); // silently do nothing if it's already preferred if (!pid.isPreferred()) { // mark all others as nonpreferred for (PatientIdentifier activePid : pid.getPatient().getActiveIdentifiers()) { if (!pid.equals(activePid) && activePid.isPreferred()) { activePid.setPreferred(false); ps.savePatientIdentifier(activePid); } } // mark this one as preferred pid.setPreferred(true); ps.savePatientIdentifier(pid); } return FragmentUtil.standardPatientObject(ui, pid.getPatient()); } |
This code is straightforward. The only difference between this example and those in previous steps is that we're converting the patientIdentifierId parameter directly into a PatientIdentifier directly in the method signature, using Spring's automatic type conversion. (Doing this required adding a converter class. TODO link to documentation of this, which is documented Type Converters.)
The next step is to add a column to the table in the gsp page, which shows either a preferred, or non-preferred icon. The non-preferred icon should be clickable.
Code Block | ||
---|---|---|
| ||
... subscribe('${ id }.set-preferred-identifier', function(message, data) { jq.post('${ ui.actionLink("setPreferredIdentifier") }', { returnFormat: 'json', patientIdentifierId: data }, function(data) { flashSuccessnotifySuccess('${ ui.escapeJs(ui.message("PatientIdentifier.setPreferred")) }'); publish('patient/${ patient.patientId }/identifiers.changed', data); }, 'json') .error(function(xhr) { fragmentActionError(xhr, "Failed to set preferred identifier"); }) }); ... ${ ui.includeFragment("widgets/table", [ id: id + "_table", columns: [ [ property: "identifierType", heading: ui.message("PatientIdentifier.identifierType") ], [ property: "identifier", userEntered: true, heading: ui.message("PatientIdentifier.identifier") ], [ property: "location", heading: ui.message("PatientIdentifier.location") ], [ heading: ui.message("PatientIdentifier.preferred"), actions: [ [ action: "none", icon: "star_16.png", showIfPropertyTrue: "preferred" ], [ action: "event", icon: "star_off16.png", event: id + ".set-preferred-identifier", property: "patientIdentifierId", showIfPropertyFalse: "preferred" ] ] ], [ actions: [ [ action: "event", icon: "delete24.png", tooltip: ui.message("PatientIdentifier.delete"), event: id + ".delete-button-clicked", property: "patientIdentifierId" ] ] ] ], rows: patient.activeIdentifiers, ifNoRowsMessage: ui.message("general.none") ]) } ... |
...
When I actually clicked around on these I noticed some odd behavior (now fixed in the head revision of the project): marking an identifier as preferred doesn't immediately move it to the top of the list on the ajax refresh, but it does move to the top of the list the next time the page is loaded. Basically, there's a bug in the core OpenMRS API where changing the preferred identifier is not reflected in the patient.getaActiveIdentifiers() method until the next time the patient is loaded from the database. I reported this as TRUNK-2188, and I introduced a workaround in the SimplePatient object used indirectly by FragmentUtil.standardPatientObject().
Step 11: Refactoring
Early on in the tutorial, we referred to splitting up our javascript functionality into that which is specific to this instance of the fragment, and that which can be split out into an external resource file. (Any javascript we can put in an external resource file can be cached by the browser, and perhaps minimized by the UI framework, thus speeding up the application over slow internet connections.) Let's go ahead and split that code out.
As we've written things so far, only a single function ("refreshPatientIdentifierTable") can be pulled out. We could rewrite some of the other methods as well, but we'll leave that as an exercise for later. (Key point: javascript that's being moved into a shared resource file must not know about the configuration of a specific instance of the fragment, nor may it reference the fragment's id, or use the 'ui' groovy functions.)
The resource file we build will be cached, but over a satellite internet connection, even checking whether a cached resource has changed can slow down a page, so we're going to combine javascript functions for all of the patient fragments into a single file.
So, let's open "webapp/src/main/webapp/scripts/coreFragments.js", and move our function in there:
Code Block | ||
---|---|---|
| ||
var patientIdentifiersFragment = {
refreshPatientIdentifierTable: function(divId, patientId) {
$('#' + divId + '_table > tbody').empty();
$.getJSON(actionLink('patientIdentifiers', 'getActiveIdentifiers', { returnFormat: "json", patientId: patientId }), function(data) {
publish(divId + "_table.show-data", data);
});
}
}
|
We've changed two things while moving this function here:
- since we're going to be gathering functions for many fragments in this file, we create a "patientIdentifiersFragment" object that contains all functions for our fragment. (Just one, for now.)
- we can't call the groovy ui.actionLink method, so we use a javascript function (defined in openmrs.js) instead.
As a result of this we need to change one line in patientIdentifiers.gsp to call this function with the "patientIdentifiersFragment." object prefix:
Code Block |
---|
patientIdentifiersFragment.refreshPatientIdentifierTable('${ id }', ${ patient.patientId })
|