Skip to main content

// 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:

InputDescription
questionnaireItemThe specific FHIR-based questionnaire item definition.
questionnaireNodeThe node instance generated by the questionnaire-item-renderer (acts as the response wrapper object for this item).
questionnaireItemResponseToSetThe 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-component and the root questionnaire-renderer-component via Dependency Injection.

Inherited State

Evaluated extensions and options from the questionnaire-item-renderer are set as local references for easier access:

  • minOccurs / maxOccurs
  • required
  • repeatable
  • disabled (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 the questionnaireResponseItem where the value is stored.
  • Example: The item-string component sets this to valueString, ensuring answers are saved in the valueString field of the response.

Initialization (ngOnInit)

The initialization process follows a specific sequence:

  1. Reference Mapping: Internal answers and the answerValuesFormArray from the questionnaireNode are mapped to local variables. This shorthand simplifies frequent access. (This must occur in ngOnInit because the required inputs are not yet available in the constructor).
  2. State Synchronization: The setAnswers method 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 string item component should verify both the presence of valueString and that the type is indeed a string. An integer component verifies valueInteger and typeof === '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 QuestionnaireResponseItemAnswer matches our FormGroup.
  • Simplified Updates: We can simply call patchValue with the QuestionnaireResponseItemAnswer object. Angular automatically maps the value to the correct key in our FormGroup, 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:

  1. Collection: Gathers both initial answers (from config) and filtered response answers.
  2. Calculation: Determines the required number of FormGroups based on minOccurs, maxOccurs, and the actual answer count (initial and/or response).
  3. Reset: Clears the current rawAnswerValuesFormArray to remove all existing groups.
  4. Rebuild: Creates new FormGroups for the calculated count and pushes them into the rawAnswerValuesFormArray.
  5. Synchronization: Sets our local answers Signal with the newly created MonaQuestionnaireFormItemAnswerBase instances. This ensures the Signal remains in sync with the FormArray for 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:

  1. Caching: If the reference to the response object remains the same, the Signal might not trigger an update.
  2. 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-renderer layer by layer

Creating Individual Answer FormGroups

The creation of a FormGroup for a single answer follows a strict sequence:

  1. Initialization: A FormGroup is created containing a FormControl mapped to the specific itemValueKey.
  2. Validation: The necessary Angular Validators are assigned to the FormControl at this stage.
  3. Data Patching: If an initialAnswer (whether sourced from the item configuration or a provided response) exists, it is patched directly into the FormGroup.
  4. Initial State Check: If the item is marked as disabled at the time of creation, the disable() method is called on the FormGroup immediately.

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 FormGroup before adding it to the array. If a FormArray is empty and disabled, adding an enabled FormGroup can inadvertently cause the FormArray to 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 FormArray is toggled (enable() or disable()).
  • Since Angular propagates status changes from a FormArray down 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 FormControl during the FormGroup creation 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 valid status of every item or individual answer when constructing the final QuestionnaireResponse.
  • 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 maxLength or placeholderText, are retrieved and set using the extensionAccessor.
  • Method Implementation: Each item must implement its own version of filteredValidSetAnswers and define its unique validatorsAndErrorMessages.
  • Value Mapping: Most importantly, the itemValueKey must be defined (e.g., set to valueString for 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 super call matters. While most items follow a standard sequence, specialized components like the choice item 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 valueString FormControl 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.