Skip to content

Commit 586acdc

Browse files
mogoodrichclaude
andauthored
HTML-883: add personAttribute tag for viewing/entering/editing person attributes (#334)
* HTML-883: add personAttribute tag for viewing/entering/editing person attributes Adds a new <personAttribute> tag that supports viewing, entering, and editing person attributes within HTML forms. Supports String, Concept, and Location PersonAttributeType formats. Features: - attributeType attribute (UUID of a PersonAttributeType) — required - VIEW mode: displays the current attribute value (concept/location rendered by name, not raw ID) - ENTER/EDIT modes: renders appropriate widget (TextFieldWidget for String, DropdownWidget for Concept/Location) - Concept type: mandatory answerConceptIds (comma-separated IDs/UUIDs) - Location type: optional tags attribute (comma-separated tag names) to filter the location dropdown - When multiple non-voided attributes of the same type exist, the most recently created one is used and a warning is logged - Blank submission voids an existing attribute New files: - PersonAttributeTagHandler.java — tag handler registered in moduleApplicationContext.xml - PersonAttributeSubmissionElement.java — widget + submission logic - PersonAttributeTagTest.java — 16 regression tests covering all cases - 5 test form XML fixtures (String, Concept, Location, Location+tag, Location-filter-only) Modified files: - moduleApplicationContext.xml — registers 'personAttribute' handler - RegressionTest-data-openmrs-2.8.xml — adds PersonAttributeType 19 (Location), a location attribute for patient 2, and two Civil Status attributes for patient 2 (for multiple-attribute warning tests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * HTML-883: add personAttribute tag for viewing/entering/editing person attributes * HTML-883: add personAttribute tag for viewing/entering/editing person attributes * HTML-883: add personAttribute tag for viewing/entering/editing person attributes (minor code review comments) * HTML-883: add personAttribute tag for viewing/entering/editing person attributes (use seralization methods to set values) * docs: add design spec for personAttribute foreignKey concept widget support Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add implementation plan for personAttribute foreignKey concept widget support Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add PersonAttributeType fixture rows 20 and 21 for foreignKey concept tests * test: add form XML files for foreignKey concept attribute tests * test: add failing tests for foreignKey concept set and concept answer widget population Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: support foreignKey on PersonAttributeType for concept widget answer population When answerConceptIds is not specified on the tag, the concept dropdown now resolves its options from PersonAttributeType.foreignKey: if the referenced concept is a set, its non-retired members are used; otherwise the concept's non-retired answers are used. answerConceptIds still takes precedence when present. * HTML-883: add personAttribute tag for viewing/entering/editing person attributes (remove superpower docs) * fix: use locale-aware concept name lookup in personAttribute concept dropdown Aligns with ObsSubmissionElement's getName(Context.getLocale(), false) pattern rather than the no-arg getName() to ensure correct locale resolution. * HTML-883: add personAttribute tag for viewing/entering/editing person attributes --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c9d9bd3 commit 586acdc

12 files changed

Lines changed: 1369 additions & 0 deletions
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
package org.openmrs.module.htmlformentry.element;
2+
3+
import java.util.ArrayList;
4+
import java.util.Collection;
5+
import java.util.Collections;
6+
import java.util.Comparator;
7+
import java.util.Date;
8+
import java.util.List;
9+
import java.util.Map;
10+
import java.util.stream.Collectors;
11+
12+
import javax.servlet.http.HttpServletRequest;
13+
14+
import org.apache.commons.logging.Log;
15+
import org.apache.commons.logging.LogFactory;
16+
import org.openmrs.Concept;
17+
import org.openmrs.ConceptAnswer;
18+
import org.openmrs.Location;
19+
import org.openmrs.LocationTag;
20+
import org.openmrs.Patient;
21+
import org.openmrs.PersonAttribute;
22+
import org.openmrs.PersonAttributeType;
23+
import org.openmrs.api.context.Context;
24+
import org.openmrs.module.htmlformentry.BadFormDesignException;
25+
import org.openmrs.module.htmlformentry.FormEntryContext;
26+
import org.openmrs.module.htmlformentry.FormEntryContext.Mode;
27+
import org.openmrs.module.htmlformentry.FormEntrySession;
28+
import org.openmrs.module.htmlformentry.FormSubmissionError;
29+
import org.openmrs.module.htmlformentry.HtmlFormEntryUtil;
30+
import org.openmrs.module.htmlformentry.action.FormSubmissionControllerAction;
31+
import org.openmrs.module.htmlformentry.comparator.OptionComparator;
32+
import org.openmrs.module.htmlformentry.widget.DropdownWidget;
33+
import org.openmrs.module.htmlformentry.widget.ErrorWidget;
34+
import org.openmrs.module.htmlformentry.widget.Option;
35+
import org.openmrs.module.htmlformentry.widget.TextFieldWidget;
36+
import org.openmrs.module.htmlformentry.widget.Widget;
37+
import org.springframework.util.StringUtils;
38+
39+
/**
40+
* Holds the widget and submission logic for the {@code <personAttribute>} tag.
41+
*
42+
* <p>Supports three PersonAttributeType formats:
43+
* <ul>
44+
* <li>{@code java.lang.String} – plain text input</li>
45+
* <li>{@code org.openmrs.Concept} – dropdown whose answer concepts are resolved in priority order:
46+
* (1) explicit {@code answerConceptIds} tag attribute (comma-separated IDs or UUIDs),
47+
* (2) {@code PersonAttributeType.foreignKey} pointing to a concept set (set members used)
48+
* or a question concept (concept answers used),
49+
* (3) {@link BadFormDesignException} if neither is configured</li>
50+
* <li>{@code org.openmrs.Location} – dropdown of locations, optionally filtered by {@code tags}</li>
51+
* </ul>
52+
*
53+
* <p>When multiple non-voided attributes of the requested type exist for the patient, the most
54+
* recently created one is used and a warning is logged.
55+
*/
56+
public class PersonAttributeSubmissionElement implements HtmlGeneratorElement, FormSubmissionControllerAction {
57+
58+
protected final Log log = LogFactory.getLog(getClass());
59+
60+
/** The resolved attribute type. */
61+
private final PersonAttributeType attributeType;
62+
63+
/** The widget used to render and submit the attribute value. */
64+
private Widget valueWidget;
65+
66+
/** The error widget (rendered in ENTER / EDIT modes only). */
67+
private ErrorWidget errorWidget;
68+
69+
/** The existing non-voided attribute (most recent if there are several), or null. */
70+
private PersonAttribute existingAttribute;
71+
72+
public PersonAttributeSubmissionElement(FormEntryContext context, Map<String, String> parameters)
73+
throws BadFormDesignException {
74+
75+
// ---- 1. Resolve the PersonAttributeType ----------------------------------------
76+
String attributeTypeUuid = parameters.get("attributeType");
77+
if (!StringUtils.hasText(attributeTypeUuid)) {
78+
throw new BadFormDesignException("<personAttribute> tag requires an \"attributeType\" attribute");
79+
}
80+
attributeType = Context.getPersonService().getPersonAttributeTypeByUuid(attributeTypeUuid);
81+
if (attributeType == null) {
82+
throw new BadFormDesignException(
83+
"<personAttribute> tag: PersonAttributeType not found for uuid=\"" + attributeTypeUuid + "\"");
84+
}
85+
86+
// ---- 2. Find existing attribute on the patient ---------------------------------
87+
Patient patient = context.getExistingPatient();
88+
if (patient != null) {
89+
List<PersonAttribute> matching = new ArrayList<PersonAttribute>();
90+
for (PersonAttribute attr : patient.getActiveAttributes()) {
91+
if (attr.getAttributeType().equals(attributeType)) {
92+
matching.add(attr);
93+
}
94+
}
95+
if (matching.size() > 1) {
96+
log.warn("Patient " + patient.getPatientId() + " has " + matching.size()
97+
+ " non-voided PersonAttributes of type \"" + attributeType.getName()
98+
+ "\"; using most recent.");
99+
matching.sort(Comparator.comparing(PersonAttribute::getDateCreated).reversed());
100+
}
101+
existingAttribute = matching.isEmpty() ? null : matching.get(0);
102+
}
103+
104+
// ---- 3. Build widget by format (all modes, including VIEW) --------------------
105+
errorWidget = new ErrorWidget();
106+
107+
if (String.class.getName().equals(attributeType.getFormat())) {
108+
valueWidget = buildStringWidget(context);
109+
110+
} else if (Concept.class.getName().equals(attributeType.getFormat())) {
111+
valueWidget = buildConceptWidget(context, parameters);
112+
113+
} else if (Location.class.getName().equals(attributeType.getFormat())) {
114+
valueWidget = buildLocationWidget(context, parameters);
115+
116+
} else {
117+
throw new BadFormDesignException("<personAttribute> tag: unsupported attribute format \"" + attributeType.getFormat()
118+
+ "\" for type \"" + attributeType.getName() + "\". Supported formats: "
119+
+ String.class.getName() + ", " + Concept.class.getName() + ", " + Location.class.getName());
120+
}
121+
122+
context.registerWidget(valueWidget);
123+
context.registerErrorWidget(valueWidget, errorWidget);
124+
}
125+
126+
// ---------------------------------------------------------------------------------
127+
// Widget builders
128+
// ---------------------------------------------------------------------------------
129+
130+
private Widget buildStringWidget(FormEntryContext context) {
131+
TextFieldWidget w = new TextFieldWidget();
132+
if (existingAttribute != null) {
133+
w.setInitialValue(existingAttribute.getValue());
134+
}
135+
return w;
136+
}
137+
138+
private Widget buildConceptWidget(FormEntryContext context, Map<String, String> parameters)
139+
throws BadFormDesignException {
140+
141+
DropdownWidget w = new DropdownWidget();
142+
w.addOption(new Option());
143+
144+
for (Concept concept : resolveAnswerConcepts(parameters)) {
145+
w.addOption(new Option(concept.getName(Context.getLocale(), false).getName(), concept.getConceptId().toString(), false));
146+
}
147+
148+
if (existingAttribute != null && StringUtils.hasText(existingAttribute.getValue())) {
149+
w.setInitialValue(existingAttribute.getValue());
150+
}
151+
152+
return w;
153+
}
154+
155+
private List<Concept> resolveAnswerConcepts(Map<String, String> parameters) throws BadFormDesignException {
156+
String answerConceptIds = parameters.get("answerConceptIds");
157+
if (StringUtils.hasText(answerConceptIds)) {
158+
return resolveConceptsFromIdList(answerConceptIds);
159+
}
160+
if (attributeType.getForeignKey() != null) {
161+
return resolveConceptsFromForeignKey(attributeType.getForeignKey());
162+
}
163+
throw new BadFormDesignException(
164+
"<personAttribute> tag: PersonAttributeType \"" + attributeType.getName()
165+
+ "\" (format=" + Concept.class.getName() + ") requires either an \"answerConceptIds\" "
166+
+ "tag attribute or a foreignKey on the attribute type pointing to a concept set or question concept");
167+
}
168+
169+
private List<Concept> resolveConceptsFromIdList(String answerConceptIds) throws BadFormDesignException {
170+
List<Concept> concepts = new ArrayList<>();
171+
for (String idOrUuid : answerConceptIds.split(",")) {
172+
String trimmed = idOrUuid.trim();
173+
if (trimmed.isEmpty()) {
174+
continue;
175+
}
176+
Concept concept = HtmlFormEntryUtil.getConcept(trimmed);
177+
if (concept == null) {
178+
throw new BadFormDesignException(
179+
"<personAttribute> tag: cannot find concept for answerConceptIds value \"" + trimmed + "\"");
180+
}
181+
concepts.add(concept);
182+
}
183+
return concepts;
184+
}
185+
186+
private List<Concept> resolveConceptsFromForeignKey(Integer foreignKey) throws BadFormDesignException {
187+
Concept fk = Context.getConceptService().getConcept(foreignKey);
188+
if (fk == null) {
189+
throw new BadFormDesignException(
190+
"<personAttribute> tag: cannot find concept for foreignKey=" + foreignKey
191+
+ " on PersonAttributeType \"" + attributeType.getName() + "\"");
192+
}
193+
if (Boolean.TRUE.equals(fk.getSet())) {
194+
return fk.getSetMembers(false);
195+
}
196+
return fk.getAnswers(false).stream()
197+
.map(ConceptAnswer::getAnswerConcept)
198+
.collect(Collectors.toList());
199+
}
200+
201+
private Widget buildLocationWidget(FormEntryContext context, Map<String, String> parameters)
202+
throws BadFormDesignException {
203+
204+
DropdownWidget w = new DropdownWidget();
205+
206+
// blank / choose prompt
207+
w.addOption(new Option(Context.getMessageSourceService().getMessage("htmlformentry.chooseALocation"), "", false));
208+
209+
// Build location list
210+
List<Location> locations;
211+
String tagsParam = parameters.get("tags");
212+
if (StringUtils.hasText(tagsParam)) {
213+
List<LocationTag> locationTags = new ArrayList<LocationTag>();
214+
for (String tagName : tagsParam.split(",")) {
215+
String trimmed = tagName.trim();
216+
if (trimmed.isEmpty()) {
217+
continue;
218+
}
219+
LocationTag tag = HtmlFormEntryUtil.getLocationTag(trimmed);
220+
if (tag == null) {
221+
throw new BadFormDesignException(
222+
"<personAttribute> tag: cannot find location tag \"" + trimmed + "\"");
223+
}
224+
locationTags.add(tag);
225+
}
226+
locations = Context.getLocationService().getLocationsHavingAnyTag(locationTags);
227+
} else {
228+
locations = Context.getLocationService().getAllLocations(false);
229+
}
230+
231+
// Build and sort options
232+
List<Option> locationOptions = new ArrayList<Option>();
233+
for (Location location : locations) {
234+
locationOptions.add(new Option(HtmlFormEntryUtil.format(location), location.getId().toString(), false));
235+
}
236+
Collections.sort(locationOptions, new OptionComparator());
237+
238+
for (Option option : locationOptions) {
239+
w.addOption(option);
240+
}
241+
242+
if (existingAttribute != null && StringUtils.hasText(existingAttribute.getValue())) {
243+
w.setInitialValue(existingAttribute.getValue());
244+
}
245+
246+
return w;
247+
}
248+
249+
// ---------------------------------------------------------------------------------
250+
// Helpers
251+
// ---------------------------------------------------------------------------------
252+
253+
/**
254+
* Converts the raw submitted string from the widget into the canonical storage value for
255+
* the attribute. For Concept and Location types, we look up the actual typed object and call
256+
* its {@link org.openmrs.Attributable#serialize()} method so that core controls the format.
257+
*/
258+
private String toStorageValue(String rawValue) {
259+
if (!StringUtils.hasText(rawValue)) {
260+
return rawValue;
261+
}
262+
String format = attributeType.getFormat();
263+
if (Concept.class.getName().equals(format)) {
264+
Concept concept = Context.getConceptService().getConcept(Integer.parseInt(rawValue));
265+
return concept != null ? concept.serialize() : rawValue;
266+
} else if (Location.class.getName().equals(format)) {
267+
Location location = Context.getLocationService().getLocation(Integer.parseInt(rawValue));
268+
return location != null ? location.serialize() : rawValue;
269+
}
270+
return rawValue;
271+
}
272+
273+
// ---------------------------------------------------------------------------------
274+
// HtmlGeneratorElement
275+
// ---------------------------------------------------------------------------------
276+
277+
@Override
278+
public String generateHtml(FormEntryContext context) {
279+
StringBuilder sb = new StringBuilder();
280+
sb.append(valueWidget.generateHtml(context));
281+
if (context.getMode() != Mode.VIEW) {
282+
sb.append(errorWidget.generateHtml(context));
283+
}
284+
return sb.toString();
285+
}
286+
287+
// ---------------------------------------------------------------------------------
288+
// FormSubmissionControllerAction
289+
// ---------------------------------------------------------------------------------
290+
291+
@Override
292+
public Collection<FormSubmissionError> validateSubmission(FormEntryContext context, HttpServletRequest submission) {
293+
// No current validation
294+
return Collections.emptyList();
295+
}
296+
297+
@Override
298+
public void handleSubmission(FormEntrySession session, HttpServletRequest submission) {
299+
FormEntryContext context = session.getContext();
300+
Patient patient = session.getPatient();
301+
if (patient == null) {
302+
log.warn("<personAttribute> handleSubmission: patient is null, skipping");
303+
return;
304+
}
305+
306+
Object rawValue = valueWidget.getValue(context, submission);
307+
String value = (rawValue == null) ? null : rawValue.toString().trim();
308+
309+
if (existingAttribute != null) {
310+
if (!StringUtils.hasText(value)) {
311+
existingAttribute.setVoided(true);
312+
existingAttribute.setVoidedBy(Context.getAuthenticatedUser());
313+
existingAttribute.setVoidReason("Cleared via htmlformentry form submission");
314+
existingAttribute.setDateVoided(new Date());
315+
} else {
316+
existingAttribute.setValue(toStorageValue(value));
317+
existingAttribute.setChangedBy(Context.getAuthenticatedUser());
318+
existingAttribute.setDateChanged(new Date());
319+
}
320+
} else {
321+
if (StringUtils.hasText(value)) {
322+
PersonAttribute newAttribute = new PersonAttribute();
323+
newAttribute.setAttributeType(attributeType);
324+
newAttribute.setValue(toStorageValue(value));
325+
patient.addAttribute(newAttribute);
326+
}
327+
}
328+
329+
// Signal that the patient needs to be persisted
330+
session.getSubmissionActions().setPatientUpdateRequired(true);
331+
}
332+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package org.openmrs.module.htmlformentry.handler;
2+
3+
import java.io.PrintWriter;
4+
import java.util.ArrayList;
5+
import java.util.Collections;
6+
import java.util.List;
7+
8+
import org.openmrs.Concept;
9+
import org.openmrs.LocationTag;
10+
import org.openmrs.PersonAttributeType;
11+
import org.openmrs.module.htmlformentry.BadFormDesignException;
12+
import org.openmrs.module.htmlformentry.FormEntryContext;
13+
import org.openmrs.module.htmlformentry.FormEntrySession;
14+
import org.openmrs.module.htmlformentry.element.PersonAttributeSubmissionElement;
15+
import org.w3c.dom.Node;
16+
17+
/**
18+
* Handles the {@code <personAttribute>} tag, which allows viewing, entering, and editing a person
19+
* attribute of type String, Concept, or Location.
20+
*
21+
* <p>Supported attributes:
22+
* <ul>
23+
* <li>{@code attributeType} (required) – UUID of the {@link PersonAttributeType}</li>
24+
* <li>{@code answerConceptIds} (required for Concept-format types) – comma-separated list of
25+
* concept IDs or UUIDs shown in the dropdown</li>
26+
* <li>{@code tags} (optional, Location-format types only) – comma-separated location tag names
27+
* used to filter the location dropdown</li>
28+
* </ul>
29+
*/
30+
public class PersonAttributeTagHandler extends AbstractTagHandler {
31+
32+
@Override
33+
protected List<AttributeDescriptor> createAttributeDescriptors() {
34+
List<AttributeDescriptor> descriptors = new ArrayList<AttributeDescriptor>();
35+
descriptors.add(new AttributeDescriptor("attributeType", PersonAttributeType.class));
36+
descriptors.add(new AttributeDescriptor("answerConceptIds", Concept.class));
37+
descriptors.add(new AttributeDescriptor("tags", LocationTag.class));
38+
return Collections.unmodifiableList(descriptors);
39+
}
40+
41+
@Override
42+
public boolean doStartTag(FormEntrySession session, PrintWriter out, Node parent, Node node)
43+
throws BadFormDesignException {
44+
FormEntryContext context = session.getContext();
45+
PersonAttributeSubmissionElement element = new PersonAttributeSubmissionElement(context, getAttributes(node));
46+
session.getSubmissionController().addAction(element);
47+
out.print(element.generateHtml(context));
48+
return false; // no body to process
49+
}
50+
51+
@Override
52+
public void doEndTag(FormEntrySession session, PrintWriter out, Node parent, Node node)
53+
throws BadFormDesignException {
54+
// nothing needed
55+
}
56+
}

api/src/main/resources/moduleApplicationContext.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@
116116
<entry key="appointments">
117117
<bean class="org.openmrs.module.htmlformentry.handler.AppointmentsTagHandler"/>
118118
</entry>
119+
<entry key="personAttribute">
120+
<bean class="org.openmrs.module.htmlformentry.handler.PersonAttributeTagHandler"/>
121+
</entry>
119122
</map>
120123
</property>
121124
</bean>

0 commit comments

Comments
 (0)