Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions changelog/CDD-3293-CMS-FIX-Dual-category-chart-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# CDD-3293-CMS-FIX-Dual-category-chart-component

# Author
Jean-Pierre Fouche

# Ticket
[CDD-3293-CMS-FIX-Dual-category-chart-component](https://ukhsa.atlassian.net/browse/CDD-3293)

# Date
26 May 2026

# Description

This change fixes the issue described in the above ticket.

Dual Category Charts are managed in the Wagtail CMS admin interface with two important fields:

```mermaid
graph TD
xaxis["x-axis"] --> | links to | PrimaryFieldValues["Primary Field Values</br>(single multi-select <select> element)"]
SecondCategory["Second Category"] --> | links to | DualChartSegments["Dual Chart Segments</br>(multiple <select> elements)"]
```

# Expected Behaviour

When the x-axis field is selected for a particular "primary category" e.g. "geography", the Primary Field Values `<select>` element should display only the appropriate regions e.g. Wales, Scotland etc.

Similarly, when the Secondary Category is selected, the individual `<select>` elements in each "segment" must be updated accordingly to show the related options.

There are in effect two sets of values, one for the category ("x-axis" / "Secondary Category") and one for the sub category selectors. The x-axis and Secondary Category selectors both use the same options in each case.

# Fix

## Isolate CSS Queries to Chart Scope

CSS queries have been modified to stay within the scope of the root node in which the chart has been created, thus doing away with `document.getElementById` and the like.
We now do (for example) `rootNode.querySelector` and this keeps the queries to the right chart.

## DualCategoryChartCardBlockDefinition behaves as a singleton

`DualCategoryChartCardBlockDefinition` has been changed to create chart instancesm using a new class, `DualCategoryChartCardBlock`. This fixes the issue of leakage between two instances of "Dual Category Chart".
128 changes: 72 additions & 56 deletions cms/dashboard/static/js/dual_category_chart_form.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ class FormStateManger {
this.secondary_field_value_state = Array.from(inputs).map(input => {
const selectedValues = [];

for (let i = 0; i < input.options.length; i++) {
const option = input.options[i];
for (const element of input.options) {
Comment thread
jeanpierrefouche-ukhsa marked this conversation as resolved.
const option = element;
if (option.selected && option.value !== "") {
selectedValues.push(option.value);
}
Expand All @@ -71,9 +71,7 @@ class FormStateManger {
* @return {boolean} whether the value should be selected
*/
isSecondaryFieldOptionValueSelected(value, segmentIndex) {
return this.secondary_field_value_state &&
this.secondary_field_value_state[segmentIndex] &&
this.secondary_field_value_state[segmentIndex].includes(value);
return this.secondary_field_value_state?.[segmentIndex]?.includes(value);
Comment thread
jeanpierrefouche-ukhsa marked this conversation as resolved.
}

/**
Expand All @@ -83,18 +81,26 @@ class FormStateManger {
* @return {boolean} whether the value should be selected
*/
isPrimaryValueSelected(value) {
return this.primary_field_values_state &&
this.primary_field_values_state.includes(value)
return this.primary_field_values_state?.includes(value)
Comment thread
jeanpierrefouche-ukhsa marked this conversation as resolved.
}
}


class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blocks.StructBlockDefinition {
class DualCategoryChartCardBlockDefinition extends globalThis.wagtailStreamField.blocks.StructBlockDefinition {
Comment thread
jeanpierrefouche-ukhsa marked this conversation as resolved.
Comment thread
phill-stanley marked this conversation as resolved.
render(placeholder, prefix, initialState, initialError) {
const block = super.render(placeholder, prefix, initialState, initialError);
const chart = new DualCategoryChartCard()
chart.render(prefix, initialState, block)
return block;
}
}

static SELECTORS = {
SEGMENTS_CONTAINER: '[class="dual-category-chart-card"] [data-streamfield-list-container]',
SEGMENT_ITEM: 'dual-category-chart-card__segments',
SECONDARY_FIELD_VALUE: '[id$="secondary_field_value"]'
class DualCategoryChartCard {
Comment thread
phill-stanley marked this conversation as resolved.
static SELECTORS = {
SEGMENTS_CONTAINER: '.dual-category-chart-segments-container-form',
Comment thread
phill-stanley marked this conversation as resolved.
SEGMENT_ITEM: '.dual-category-chart-card__segments',
SECONDARY_FIELD_VALUE: '[id$="secondary_field_value"]',
DUAL_CATEGORY_CHART_CARD: 'dual-category-chart-card'
};

static FIELD_SUFFIXES = {
Expand Down Expand Up @@ -135,7 +141,7 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo
* @param prefix - {string} html form prefix for element IDs
*/
setupDataScript(prefix) {
const script_id = `${prefix}-${DualCategoryChartCardBlockDefinition.FIELD_SUFFIXES.DATA_SCRIPT}`;
const script_id = `${prefix}-${DualCategoryChartCard.FIELD_SUFFIXES.DATA_SCRIPT}`;
this.data_script = document.getElementById(script_id);

if (!this.data_script) {
Expand All @@ -161,19 +167,19 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo
* `primary_field_values` options with age related options.
* @param prefix - {string} html form prefix for element IDs
Comment thread
phill-stanley marked this conversation as resolved.
*/
setupCategoryFormFields(prefix) {
const { FIELD_SUFFIXES } = DualCategoryChartCardBlockDefinition;
setupCategoryFormFields(prefix, rootNode) {
const { FIELD_SUFFIXES } = DualCategoryChartCard;

this.x_axis_field = document.getElementById(`${prefix}-${FIELD_SUFFIXES.X_AXIS}`);
this.geography_type_field = document.getElementById(`${prefix}-${FIELD_SUFFIXES.GEOGRAPHY}`);
this.secondary_category_field = document.getElementById(`${prefix}-${FIELD_SUFFIXES.SECONDARY_CATEGORY}`);
this.x_axis_field = rootNode.querySelector(`select#${prefix}-${FIELD_SUFFIXES.X_AXIS}`);
this.geography_type_field = rootNode.querySelector(`select#${prefix}-${FIELD_SUFFIXES.GEOGRAPHY}`);
this.secondary_category_field = rootNode.querySelector(`select#${prefix}-${FIELD_SUFFIXES.SECONDARY_CATEGORY}`);

const missingFields = [];
if (!this.x_axis_field) missingFields.push(FIELD_SUFFIXES.X_AXIS);
if (!this.geography_type_field) missingFields.push(FIELD_SUFFIXES.GEOGRAPHY);
if (!this.secondary_category_field) missingFields.push(FIELD_SUFFIXES.SECONDARY_CATEGORY);

if(missingFields.length > 0) {
if (missingFields.length > 0) {
console.error(`Category form fields not found: ${missingFields.join(', ')}`, {
prefix,
x_axis_field: this.x_axis_field,
Expand All @@ -190,25 +196,24 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo
* to the category selected in `x_axis` field from our `primary form fields`
* @param prefix
*/
setupSubCategoryFormFields(prefix) {
const { FIELD_SUFFIXES, SELECTORS } = DualCategoryChartCardBlockDefinition;
setupSubCategoryFormFields(prefix, rootNode) {
Comment thread
phill-stanley marked this conversation as resolved.
const { FIELD_SUFFIXES, SELECTORS } = DualCategoryChartCard;

this.primary_field_values_inputs = document.getElementById(
`${prefix}-${FIELD_SUFFIXES.PRIMARY_VALUES}`
this.primary_field_values_inputs = rootNode.querySelector(
`select#${prefix}-${FIELD_SUFFIXES.PRIMARY_VALUES}`
);
this.secondary_field_value_inputs = document.querySelectorAll(
this.secondary_field_value_inputs = rootNode.querySelectorAll(
`[id^="${prefix}-${FIELD_SUFFIXES.SEGMENTS}-"]${SELECTORS.SECONDARY_FIELD_VALUE}`
);

const missingFields = [];
if (!this.primary_field_values_inputs) missingFields.push(FIELD_SUFFIXES.PRIMARY_VALUES);
if (!this.secondary_field_value_inputs) missingFields.push(FIELD_SUFFIXES.SECONDARY_VALUES);

if (missingFields.length > 0) {
console.error(`Sub-category form fields not found: ${missingFields.join(', ')}`, {
prefix,
primary_field_values: this.primary_field_values_inputs,
secondary_field_value: this.primary_field_values_inputs?.length || 0
primary_field_values: this.primary_field_values_inputs?.length || 0,
Comment thread
jeanpierrefouche-ukhsa marked this conversation as resolved.
secondary_field_value: this.secondary_field_value_inputs?.length || 0,
});
}
}
Expand All @@ -219,25 +224,25 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo
* and restore saved state for existing content
*/
initialiseFieldOptions() {
if(!this.x_axis_field || !this.secondary_category_field || !this.secondary_category_choices) {
if (!this.x_axis_field || !this.secondary_category_field || !this.secondary_category_choices) {
return;
}

const { DEFAULT_OPTION } = DualCategoryChartCardBlockDefinition;
const { DEFAULT_OPTION } = DualCategoryChartCard;

if(this.x_axis_field && this.primary_field_values_inputs) {
if (this.x_axis_field && this.primary_field_values_inputs) {
this.updatePrimaryFieldValueOptions(this.x_axis_field.value);
}

if(this.secondary_category_field && this.secondary_field_value_inputs) {
if (this.secondary_category_field && this.secondary_field_value_inputs) {
this.updateSecondaryFieldValueOptions(this.secondary_category_field.value);
}

if(!this.x_axis_field.value && this.primary_field_values_inputs) {
if (!this.x_axis_field.value && this.primary_field_values_inputs) {
this.primary_field_values_inputs.innerHTML = `<option value="${DEFAULT_OPTION.VALUE}">${DEFAULT_OPTION.TEXT}</option>`;
}

if(!this.secondary_category_field.value && this.secondary_field_value_inputs.length > 0) {
if (!this.secondary_category_field.value && this.secondary_field_value_inputs.length > 0) {
this.secondary_field_value_inputs.forEach(input => {
input.innerHTML = `<option value="${DEFAULT_OPTION.VALUE}">${DEFAULT_OPTION.TEXT}</option>`;
});
Expand All @@ -252,7 +257,7 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo
* @param subcategory_key {string} representing the sub category key (primary category)
*/
getFieldOptions(subcategory_key) {
const { GEOGRAPHY_SUBCATEGORY_KEY } = DualCategoryChartCardBlockDefinition.FIELD_SUFFIXES
const { GEOGRAPHY_SUBCATEGORY_KEY } = DualCategoryChartCard.FIELD_SUFFIXES

if (subcategory_key && subcategory_key !== GEOGRAPHY_SUBCATEGORY_KEY) {
return this.secondary_category_choices[subcategory_key] || [];
Expand All @@ -271,7 +276,7 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo
* @param subcategory_key - {string} the subcategory key used to retrieve the primary field value options.
*/
updatePrimaryFieldValueOptions(subcategory_key) {
const { DEFAULT_OPTION } = DualCategoryChartCardBlockDefinition;
const { DEFAULT_OPTION } = DualCategoryChartCard;

this.primary_field_values_inputs.innerHTML = `<option value="${DEFAULT_OPTION.VALUE}">${DEFAULT_OPTION.TEXT}</option>`;

Expand All @@ -296,7 +301,7 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo
* @param subcategory_key - {string} the subcategory key used to retrieve the primary field value options.
*/
updateSecondaryFieldValueOptions(subcategory_key) {
const { DEFAULT_OPTION } = DualCategoryChartCardBlockDefinition;
const { DEFAULT_OPTION } = DualCategoryChartCard;

this.secondary_field_value_inputs.forEach((secondary_field_value, index) => {
secondary_field_value.innerHTML = `<option value="${DEFAULT_OPTION.VALUE}">${DEFAULT_OPTION.TEXT}</option>`;
Expand Down Expand Up @@ -324,11 +329,11 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo
* `SecondaryFormFields` and then update the options of these lists to ensure they align with the primary fields.
* @param prefix
*/
observeSegmentBlocks(prefix) {
const { SELECTORS } = DualCategoryChartCardBlockDefinition;
const segments_container = document.querySelector(SELECTORS.SEGMENTS_CONTAINER)
observeSegmentBlocks(prefix, rootNode) {
const { SELECTORS } = DualCategoryChartCard;
const segments_container = rootNode.querySelector(SELECTORS.SEGMENTS_CONTAINER)

if(!segments_container) {
if (!segments_container) {
Comment thread
jeanpierrefouche-ukhsa marked this conversation as resolved.
console.warn("Segments container not found - observer disabled");
return;
}
Expand All @@ -337,11 +342,10 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo
mutations.forEach((mutation) => {
if (mutation.type === "childList") {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1 && node.classList.contains(SELECTORS.SEGMENT_ITEM)) {

if (node.nodeType === 1 && node.matches(SELECTORS.SEGMENT_ITEM)) {
this.stateManager.storeCurrentSelections(this.secondary_field_value_inputs);

this.setupSubCategoryFormFields(prefix);
this.setupSubCategoryFormFields(prefix, rootNode);
if (this.secondary_category_field.value) {
this.updateSecondaryFieldValueOptions(this.secondary_category_field.value);
}
Expand All @@ -363,8 +367,8 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo
* @name setupEvents
* @description Setup event handlers for dynamic choice fields `secondary_category` and `x_axis_field`.
*/
setupEvents() {
const { FIELD_SUFFIXES } = DualCategoryChartCardBlockDefinition;
setupEvents(rootNode) {
const { FIELD_SUFFIXES } = DualCategoryChartCard;

this.x_axis_field.addEventListener("change", (evt) => {
this.stateManager.clearPrimaryFieldState();
Expand All @@ -374,23 +378,37 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo
this.secondary_category_field.addEventListener("change", (evt) => {
this.stateManager.clearSecondaryFieldState();
this.updateSecondaryFieldValueOptions(evt.target.value);

// clear all segments
Comment thread
phill-stanley marked this conversation as resolved.
rootNode
.querySelectorAll('.dual-category-chart-card__segments')
.forEach(segments => {
const section = segments.closest('section[id$="-section"]');
section
?.querySelector('[data-streamfield-action="DELETE"]')
?.click();
});
});

this.geography_type_field.addEventListener("change", (evt) => {
if(this.x_axis_field.value === FIELD_SUFFIXES.GEOGRAPHY_SUBCATEGORY_KEY) {
if (this.x_axis_field.value === FIELD_SUFFIXES.GEOGRAPHY_SUBCATEGORY_KEY) {
this.stateManager.clearPrimaryFieldState();
this.updatePrimaryFieldValueOptions(this.x_axis_field.value);
}

if(this.secondary_category_field.value === FIELD_SUFFIXES.GEOGRAPHY_SUBCATEGORY_KEY) {
if (this.secondary_category_field.value === FIELD_SUFFIXES.GEOGRAPHY_SUBCATEGORY_KEY) {
this.stateManager.clearSecondaryFieldState();
this.updateSecondaryFieldValueOptions(this.secondary_category_field.value);
}
});
}

render(placeholder, prefix, initialState, initialError) {
const block = super.render(placeholder, prefix, initialState, initialError);
render(prefix, initialState, block) {
// from the telepath StructBlock representation,
Comment thread
phill-stanley marked this conversation as resolved.
// find the DOM element in which this chart is rendered.
const rootNode = Array.from(block.container).find(
el => el.className === DualCategoryChartCard.SELECTORS.DUAL_CATEGORY_CHART_CARD
)

// initialise form state
this.stateManager.initialise(initialState);
Expand All @@ -399,22 +417,20 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo
this.setupDataScript(prefix);

// setup primary and secondary form fields
this.setupCategoryFormFields(prefix);
this.setupSubCategoryFormFields(prefix);
this.setupCategoryFormFields(prefix, rootNode);
this.setupSubCategoryFormFields(prefix, rootNode);

// Initial population of field options based on current/initial values
this.initialiseFieldOptions();

// Setup observer for watching segments list and event handlers on
this.observeSegmentBlocks(prefix);
this.setupEvents();

return block;
this.observeSegmentBlocks(prefix, rootNode);
this.setupEvents(rootNode);
}

}

window.telepath.register(
globalThis.telepath.register(
Comment thread
jeanpierrefouche-ukhsa marked this conversation as resolved.
'cms.dynamic_content.cards.DualCategoryChartCard',
DualCategoryChartCardBlockDefinition,
);
3 changes: 3 additions & 0 deletions cms/dynamic_content/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ class Meta:
class DualCategoryChartSegmentComponents(blocks.StreamBlock):
segment = DualCategoryChartSegmentComponent()

class Meta:
Comment thread
phill-stanley marked this conversation as resolved.
Comment thread
jeanpierrefouche-ukhsa marked this conversation as resolved.
form_classname = "dual-category-chart-segments-container-form"


class HeadlineNumberComponent(elements.BaseMetricsElement):
body = blocks.TextBlock(required=False, help_text=help_texts.OPTIONAL_BODY_FIELD)
Expand Down
Loading