// todo link to source
This documentation covers files located at: Questionnaire Item Base
Questionnaire Item Base Component
The QuestionnaireItemBase component serves as the abstract base class for all specific item components within our questionnaire system.
Purpose & Architecture
By extending this class, specific components inherit core logic to ensure a "DRY" (Don't Repeat Yourself) codebase. While this inheritance layer adds a degree of complexity to the execution flow, it is a necessary trade-off: the logic required for FHIR-based questionnaires is extensive, and centralizing it prevents highly redundant and unmanageable code across the various item types.
Input Parameters
The component requires the following inputs to function correctly:
| Input | Description |
|---|---|
questionnaireItem | The specific FHIR-based questionnaire item definition. |
questionnaireNode | The node instance generated by the questionnaire-item-renderer (acts as the response wrapper object for this item). |
questionnaireItemResponseToSet | The specific response object associated with the item, pre-filtered and passed by the questionnaire-item-renderer. |
Services & Injection
The base component provides and injects core dependencies:
fhirI18nService: Our internal service for FHIR internationalization.
- Injected Context: The component provides access to the current
questionnaire-item-renderer-componentand the rootquestionnaire-renderer-componentvia Dependency Injection.
Inherited State
Evaluated extensions and options from the questionnaire-item-renderer are set as local references for easier access:
minOccurs/maxOccursrequiredrepeatabledisabled(The pre-calculated state determined by the renderer logic).
Implementation Details
Placeholder Requirements
Specific item components are expected to override certain properties, most importantly:
itemValueKey: This defines the specific key within thequestionnaireResponseItemwhere the value is stored.- Example: The
item-stringcomponent sets this tovalueString, ensuring answers are saved in thevalueStringfield of the response.
Initialization (ngOnInit)
The initialization process follows a specific sequence:
- Reference Mapping: Internal
answersand theanswerValuesFormArrayfrom thequestionnaireNodeare mapped to local variables. This shorthand simplifies frequent access. (This must occur inngOnInitbecause the required inputs are not yet available in the constructor). - State Synchronization: The
setAnswersmethod is called to populate the local formArray and with this also the linked answers inside the node/response-item.
Answer Filtering
The setAnswers logic uses both initialAnswers (from the questionnaire config) and responseAnswers (from an existing response) as a foundation.
To maintain strict type safety, specific components must implement filteredValidSetAnswers:
- The Logic: While the base component could generically match via
itemValueKey, specific checks are safer. - Example: A
stringitem component should verify both the presence ofvalueStringand that the type is indeed astring. Anintegercomponent verifiesvalueIntegerandtypeof === 'number'. - This ensures maximum flexibility for implementing type-specific validation for every questionnaire item.
Answers and Repeatable Items
As mentioned in the fundamentals, non-group items are repeated based on answer level. To handle this, we utilize a FormArray and an array of answers within each itemNode.
FormGroup vs. FormControl
A QuestionnaireResponseItemAnswer is defined such that the value is stored under a specific key depending on the item type (with the exception of choice, which can vary). Therefore, we use a FormGroup instead of a simple FormControl.
The FormGroup uses our defined itemValueKey as its key. The DOM input element then binds to the FormControl nested under this key. This structure offers a significant advantage:
- Seamless Mapping: The structure of a
QuestionnaireResponseItemAnswermatches ourFormGroup. - Simplified Updates: We can simply call
patchValuewith theQuestionnaireResponseItemAnswerobject. Angular automatically maps the value to the correct key in ourFormGroup, ignoring any irrelevant keys within the response object. This eliminates the need for manual key-lookup logic.
Note: Although we use
patchValue, the operation effectively functions as a "set" because we rebuild/reset the answers entirely whenever a new response is provided.
The setAnswers Flow
The setAnswers method executes the following sequence:
- Collection: Gathers both initial answers (from config) and filtered response answers.
- Calculation: Determines the required number of
FormGroupsbased onminOccurs,maxOccurs, and the actual answer count (initial and/or response). - Reset: Clears the current
rawAnswerValuesFormArrayto remove all existing groups. - Rebuild: Creates new
FormGroupsfor the calculated count and pushes them into therawAnswerValuesFormArray. - Synchronization: Sets our local
answersSignal with the newly createdMonaQuestionnaireFormItemAnswerBaseinstances. This ensures the Signal remains in sync with theFormArrayfor DOM rendering.
Response Subscription & Reset Logic
setAnswers is called during ngOnInit and must also be triggered whenever the questionnaire-renderer receives a new response. We subscribe to an Observable stream from the renderer for this purpose.
A global update stream is required because the standard Signal input flow is insufficient for two reasons:
- Caching: If the reference to the response object remains the same, the Signal might not trigger an update.
- Incomplete Responses: Responses are not always comprehensive for every item level. If no response exists for a specific level, nothing is passed down, which would fail to trigger a necessary reset.
By using a global stream, we ensure every item correctly rebuilds its internal state whenever the overall questionnaire response is updated.
Note: Although we use a stream for the response update signal, the actual response is not passed through this stream. This is because we don't know where the current items response is located inside the overall response object (duplicate linkIds in response). As mentioned the response object is passed and filtered through the
questionnaire-item-rendererlayer by layer
Creating Individual Answer FormGroups
The creation of a FormGroup for a single answer follows a strict sequence:
- Initialization: A
FormGroupis created containing aFormControlmapped to the specificitemValueKey. - Validation: The necessary Angular
Validatorsare assigned to theFormControlat this stage. - Data Patching: If an
initialAnswer(whether sourced from the item configuration or a provided response) exists, it is patched directly into theFormGroup. - Initial State Check: If the item is marked as
disabledat the time of creation, thedisable()method is called on theFormGroupimmediately.
Disabled Logic & State Management
The component manages the disabled state using two layers of protection:
Creation Guard
As noted in step 4 above, individual FormGroups are disabled upon instantiation if the item state requires it. This must happen before the group is pushed into the FormArray.
Note on Angular Behavior: It is critical to disable the
FormGroupbefore adding it to the array. If aFormArrayis empty and disabled, adding an enabledFormGroupcan inadvertently cause theFormArrayto switch back to an "enabled" state.
Reactive Updates
To handle state changes after initialization, we utilize an Angular Effect that monitors the disabled signal of the item.
- When the signal changes, the entire
FormArrayis toggled (enable()ordisable()). - Since Angular propagates status changes from a
FormArraydown to its children, managing the state at the array level is sufficient to keep all UI elements in sync.
Add and remove answers (user based)
Das hinzufügen und entfernen von antworten ist mit diesem setup relativ einfach. Ob hinzufügen oder entfernen erlaubt ist können wir einfach über das bereitgestellt minOccur und maxOccur überprüfen.
Nun müssen nur noch im richtigen Index im FormArray sowie im answer Array neue MonaQuestionnaireFormItemAnswerBase bzw. FormGroups hinzugefügt oder entfernt werden.
Wichtig ist hier nur das nach dem Update auch Notifies the questionnaire-logic-service by calling triggerValueChange() and `triggerPathNodeMapUpdate() aufgerufen wird um ein repsonse update zu veranlassen.
Validation and Error Handling
By leveraging the logic of our internal Component Library Form-Fields, we can utilize their built-in error message handling. This requires an error message map to be passed to the rendered component.
The validatorsAndErrorMessages Property
The QuestionnaireItemBase provides the validatorsAndErrorMessages property, which must be overridden in each specific item implementation.
- Customization: You can define any number of Angular Validators here.
- Assignment: These validators are automatically assigned to the
FormControlduring theFormGroupcreation process described in the previous sections.
Benefits of this Approach
- Standardization: We utilize Angular's native form validation engine, offloading complex logic to a proven framework.
- Response Integrity: Because the state is managed within the Angular Form API, we can easily check the
validstatus of every item or individual answer when constructing the finalQuestionnaireResponse. - Consistent UI: This ensures that error messages are displayed uniformly across the entire questionnaire, regardless of the specific item type.
Specific Item Component Implementation
Since the majority of the business logic is abstracted into the QuestionnaireItemBase component, the implementation of specific items remains highly consistent across the library. For the most up-to-date details, it is always recommended to review the source code of the individual items.
However, the following example of the String Item demonstrates how these concepts are applied in practice:
export class QuestionnaireItemStringComponent extends QuestionnaireItemBaseComponent implements OnInit {
override itemValueKey: keyof QuestionnaireResponseItemAnswer = 'valueString';
/** item-specific extensions **/
maxLength = this.questionnaireItemRenderer.extensionAccessor(FHIR_EXTENSIONS.QUESTIONNAIRE.MAX_LENGTH);
minLength = this.questionnaireItemRenderer.extensionAccessor(FHIR_EXTENSIONS.QUESTIONNAIRE.MIN_LENGTH);
regexPattern = this.questionnaireItemRenderer.extensionAccessor(FHIR_EXTENSIONS.QUESTIONNAIRE.REGEX);
placeholderText = this.questionnaireItemRenderer.extensionAccessor(FHIR_EXTENSIONS.QUESTIONNAIRE.ENTRY_FORMAT, {
asExtension: true,
});
labelText = this.fhirI18nService.getTranslatedText(this.questionnaireItem, 'text');
translatedPlaceholderText = this.fhirI18nService.getTranslatedText(this.placeholderText, 'valueString');
override errorMessages = signal<Record<string, string>>({});
/** validate patch answers for correct types and non-empty values **/
override filteredValidSetAnswers = computed(() => {
return (this.questionnaireItemResponseToSet()?.answer ?? []).filter(
answer => !!answer[this.itemValueKey] && typeof answer[this.itemValueKey] === 'string',
);
});
override validatorsAndErrorMessages = computed(() => {
const { validators, errorMessages } = getBaseValidators({
required: this.required(),
minLength: this.minLength(),
maxLength: this.maxLength(),
});
const pattern = this.regexPattern();
if (pattern) {
validators.push(Validators.pattern(pattern));
// todo add pattern error message
}
return { validators, errorMessages };
});
constructor() {
super();
}
}
As shown in the examples, the actual implementation code for a specific item is quite minimal. Most of the heavy lifting is handled by the base class.
Key Implementation Steps:
- Item Configuration: Specific configurations, such as
maxLengthorplaceholderText, are retrieved and set using theextensionAccessor. - Method Implementation: Each item must implement its own version of
filteredValidSetAnswersand define its uniquevalidatorsAndErrorMessages. - Value Mapping: Most importantly, the
itemValueKeymust be defined (e.g., set tovalueStringfor text inputs).
Handling Lifecycle Hooks:
When extending the base class, ensure that the super constructor is called correctly. If you utilize other lifecycle hooks like ngOnInit, you must call super.ngOnInit() to ensure the base logic (like answer initialization) is executed.
Note: The placement of the
supercall matters. While most items follow a standard sequence, specialized components like thechoiceitem may require a unique implementation order to handle their specific data structures.
Template:
@for (answer of answers(); track answer.id; let answerIndex = $index) {
@if (answer.answerValueWrapper.controls.valueString; as control) {
<!-- to reduce dom load, only render the input if the item is not disabled, BUT we need to render the child renderer -->
@if (!disabled()) {
<div class="relative mt-4">
<mona-text-form-field
type="text"
[formControl]="control"
[testId]="testId()"
[label]="labelText()"
[required]="questionnaireItemRenderer.isRequired()"
[placeholder]="translatedPlaceholderText()"
[errorMessages]="errorMessages()">
</mona-text-form-field>
@if (questionnaireItemRenderer.isRepeatable()) {
<monaqr-questionnaire-item-repeat-options
[showAdd]="_canAdd()"
[showRemove]="_canRemove()"
(onAdd)="_changeAnswerCount({ operation: 'ADD', index: answerIndex + 1 })"
(onRemove)="_changeAnswerCount({ operation: 'REMOVE', index: answerIndex })"
class="absolute top-0 right-0 -translate-y-2"></monaqr-questionnaire-item-repeat-options>
}
</div>
}
@for (
childItem of questionnaireItem().item ?? [];
track childItem.linkId + $index;
let itemIndex = $index
) {
<div class="pl-8 border-l-4 flex flex-col gap-10">
<monaqr-questionnaire-item-renderer
[renderNodePath]="getAnswerNodePath(answerIndex, itemIndex)"
[parentQuestionnaireNode]="questionnaireNode()"
[questionnaireNodesLayer]="answer.item ?? []"
[questionnaireItem]="childItem"
[questionnaireResponseSetItems]="filteredValidSetAnswers()[answerIndex]?.item ?? []">
</monaqr-questionnaire-item-renderer>
</div>
}
}
}
Template Structure
The template utilizes many of the properties provided by the base component. The most critical architectural detail is that the entire template is wrapped in a @for loop iterating over each answer.
This repetition is essential because every answer entry requires its own input field, and any nested elements must be rendered per answer instance.
Key Template Features:
- FormControl Binding: You can see how the
valueStringFormControl of each specific answer is passed directly to the form-field component. - Conditional Rendering for Disabled State: To optimize performance, the template minimizes rendering when the element is
disabled. In this state, only the absolutely necessary child elements are rendered, reducing the overall DOM weight.