From f10a0918a71602b7790d0a2173a72a8765f14d47 Mon Sep 17 00:00:00 2001 From: Jean Pierre Fouche <261405437+jeanpierrefouche-ukhsa@users.noreply.github.com> Date: Fri, 22 May 2026 19:15:06 +0100 Subject: [PATCH 1/4] task/CDD-3293-CMS-FIX-Dual-category-chart-component fix secondary categories not updating the issue is with the selector for the segments form - this was not finding the form task/CDD-3293-CMS-FIX-Dual-category-chart-component delete all segments on change task/CDD-3293-CMS-FIX-Dual-category-chart-component Fix observer - added appropriate class attribute, fixed the matcher to use appropriate css call instead of string.contains --- .../static/js/dual_category_chart_form.js | 60 ++++++++++--------- cms/dynamic_content/components.py | 3 + 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/cms/dashboard/static/js/dual_category_chart_form.js b/cms/dashboard/static/js/dual_category_chart_form.js index 5be66baa8..4835b2d00 100644 --- a/cms/dashboard/static/js/dual_category_chart_form.js +++ b/cms/dashboard/static/js/dual_category_chart_form.js @@ -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) { + const option = element; if (option.selected && option.value !== "") { selectedValues.push(option.value); } @@ -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); } /** @@ -83,17 +81,16 @@ 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) } } -class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blocks.StructBlockDefinition { +class DualCategoryChartCardBlockDefinition extends globalThis.wagtailStreamField.blocks.StructBlockDefinition { - static SELECTORS = { - SEGMENTS_CONTAINER: '[class="dual-category-chart-card"] [data-streamfield-list-container]', - SEGMENT_ITEM: 'dual-category-chart-card__segments', + static SELECTORS = { + SEGMENTS_CONTAINER: '.dual-category-chart-segments-container-form', + SEGMENT_ITEM: '.dual-category-chart-card__segments', SECONDARY_FIELD_VALUE: '[id$="secondary_field_value"]' }; @@ -173,7 +170,7 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo 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, @@ -203,12 +200,11 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo 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, + secondary_field_value: this.secondary_field_value_inputs?.length || 0, }); } } @@ -219,25 +215,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; - 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 = ``; } - 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 = ``; }); @@ -325,10 +321,10 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo * @param prefix */ observeSegmentBlocks(prefix) { - const { SELECTORS } = DualCategoryChartCardBlockDefinition; + const { SELECTORS } = DualCategoryChartCardBlockDefinition; const segments_container = document.querySelector(SELECTORS.SEGMENTS_CONTAINER) - if(!segments_container) { + if (!segments_container) { console.warn("Segments container not found - observer disabled"); return; } @@ -337,8 +333,7 @@ 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); @@ -374,15 +369,25 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo this.secondary_category_field.addEventListener("change", (evt) => { this.stateManager.clearSecondaryFieldState(); this.updateSecondaryFieldValueOptions(evt.target.value); + + // clear all segments + document + .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); } @@ -390,6 +395,7 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo } render(placeholder, prefix, initialState, initialError) { + const block = super.render(placeholder, prefix, initialState, initialError); // initialise form state @@ -414,7 +420,7 @@ class DualCategoryChartCardBlockDefinition extends window.wagtailStreamField.blo } -window.telepath.register( +globalThis.telepath.register( 'cms.dynamic_content.cards.DualCategoryChartCard', DualCategoryChartCardBlockDefinition, ); diff --git a/cms/dynamic_content/components.py b/cms/dynamic_content/components.py index 55b26f576..6f8d7208b 100644 --- a/cms/dynamic_content/components.py +++ b/cms/dynamic_content/components.py @@ -69,6 +69,9 @@ class Meta: class DualCategoryChartSegmentComponents(blocks.StreamBlock): segment = DualCategoryChartSegmentComponent() + class Meta: + form_classname = "dual-category-chart-segments-container-form" + class HeadlineNumberComponent(elements.BaseMetricsElement): body = blocks.TextBlock(required=False, help_text=help_texts.OPTIONAL_BODY_FIELD) From d4f90a8ad9f71538ad25cf0a1a764e26b4b69e7a Mon Sep 17 00:00:00 2001 From: Jean Pierre Fouche <261405437+jeanpierrefouche-ukhsa@users.noreply.github.com> Date: Tue, 26 May 2026 23:04:09 +0100 Subject: [PATCH 2/4] task/CDD-3293-CMS-FIX-Dual-category-chart-component fix multiple charts issue: - DualCategoryChartCardBlockDefinition is a singleton and so the code needs to treat each chart as a new instance. - CSS queries must be scoped to rootNode, this being the DOM root element on which each chart is rendered. --- .../static/js/dual_category_chart_form.js | 67 ++++++++++--------- 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/cms/dashboard/static/js/dual_category_chart_form.js b/cms/dashboard/static/js/dual_category_chart_form.js index 4835b2d00..1cf5c9374 100644 --- a/cms/dashboard/static/js/dual_category_chart_form.js +++ b/cms/dashboard/static/js/dual_category_chart_form.js @@ -87,7 +87,15 @@ class FormStateManger { class DualCategoryChartCardBlockDefinition extends globalThis.wagtailStreamField.blocks.StructBlockDefinition { + render(placeholder, prefix, initialState, initialError) { + const block = super.render(placeholder, prefix, initialState, initialError); + const chart = new DualCategoryChartCard() + chart.render(prefix, initialState, block) + return block; + } +} +class DualCategoryChartCard { static SELECTORS = { SEGMENTS_CONTAINER: '.dual-category-chart-segments-container-form', SEGMENT_ITEM: '.dual-category-chart-card__segments', @@ -132,7 +140,7 @@ class DualCategoryChartCardBlockDefinition extends globalThis.wagtailStreamField * @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) { @@ -158,12 +166,12 @@ class DualCategoryChartCardBlockDefinition extends globalThis.wagtailStreamField * `primary_field_values` options with age related options. * @param prefix - {string} html form prefix for element IDs */ - 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); @@ -187,13 +195,13 @@ class DualCategoryChartCardBlockDefinition extends globalThis.wagtailStreamField * 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) { + 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}` ); @@ -219,7 +227,7 @@ class DualCategoryChartCardBlockDefinition extends globalThis.wagtailStreamField return; } - const { DEFAULT_OPTION } = DualCategoryChartCardBlockDefinition; + const { DEFAULT_OPTION } = DualCategoryChartCard; if (this.x_axis_field && this.primary_field_values_inputs) { this.updatePrimaryFieldValueOptions(this.x_axis_field.value); @@ -248,7 +256,7 @@ class DualCategoryChartCardBlockDefinition extends globalThis.wagtailStreamField * @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] || []; @@ -267,7 +275,7 @@ class DualCategoryChartCardBlockDefinition extends globalThis.wagtailStreamField * @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 = ``; @@ -292,7 +300,7 @@ class DualCategoryChartCardBlockDefinition extends globalThis.wagtailStreamField * @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 = ``; @@ -320,9 +328,9 @@ class DualCategoryChartCardBlockDefinition extends globalThis.wagtailStreamField * `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) { console.warn("Segments container not found - observer disabled"); @@ -336,7 +344,7 @@ class DualCategoryChartCardBlockDefinition extends globalThis.wagtailStreamField 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); } @@ -358,8 +366,8 @@ class DualCategoryChartCardBlockDefinition extends globalThis.wagtailStreamField * @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(); @@ -371,7 +379,7 @@ class DualCategoryChartCardBlockDefinition extends globalThis.wagtailStreamField this.updateSecondaryFieldValueOptions(evt.target.value); // clear all segments - document + rootNode .querySelectorAll('.dual-category-chart-card__segments') .forEach(segments => { const section = segments.closest('section[id$="-section"]'); @@ -394,9 +402,8 @@ class DualCategoryChartCardBlockDefinition extends globalThis.wagtailStreamField }); } - render(placeholder, prefix, initialState, initialError) { - - const block = super.render(placeholder, prefix, initialState, initialError); + render(prefix, initialState, block) { + const rootNode = block.container[4] // initialise form state this.stateManager.initialise(initialState); @@ -405,17 +412,15 @@ class DualCategoryChartCardBlockDefinition extends globalThis.wagtailStreamField 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); } } From 811096361e32680b77ea6eecb8bebddda082bc9d Mon Sep 17 00:00:00 2001 From: Jean Pierre Fouche <261405437+jeanpierrefouche-ukhsa@users.noreply.github.com> Date: Tue, 26 May 2026 23:53:45 +0100 Subject: [PATCH 3/4] task/CDD-3293-CMS-FIX-Dual-category-chart-component Changelog document --- ...3-CMS-FIX-Dual-category-chart-component.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 changelog/CDD-3293-CMS-FIX-Dual-category-chart-component.md diff --git a/changelog/CDD-3293-CMS-FIX-Dual-category-chart-component.md b/changelog/CDD-3293-CMS-FIX-Dual-category-chart-component.md new file mode 100644 index 000000000..7bda0152f --- /dev/null +++ b/changelog/CDD-3293-CMS-FIX-Dual-category-chart-component.md @@ -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
(single multi-select elements)"] +``` + +# Expected Behaviour + +When the x-axis field is selected for a particular "primary category" e.g. "geography", the Primary Field Values `` 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". From d0122b80efb0152489b305a17bb0bfec4c3cff29 Mon Sep 17 00:00:00 2001 From: Jean Pierre Fouche <261405437+jeanpierrefouche-ukhsa@users.noreply.github.com> Date: Thu, 28 May 2026 16:31:55 +0100 Subject: [PATCH 4/4] task/CDD-3293-CMS-FIX-Dual-category-chart-component Find rootNode dynamically instead of hard-coding to 4th element --- cms/dashboard/static/js/dual_category_chart_form.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cms/dashboard/static/js/dual_category_chart_form.js b/cms/dashboard/static/js/dual_category_chart_form.js index 1cf5c9374..2ed96facd 100644 --- a/cms/dashboard/static/js/dual_category_chart_form.js +++ b/cms/dashboard/static/js/dual_category_chart_form.js @@ -99,7 +99,8 @@ class DualCategoryChartCard { static SELECTORS = { SEGMENTS_CONTAINER: '.dual-category-chart-segments-container-form', SEGMENT_ITEM: '.dual-category-chart-card__segments', - SECONDARY_FIELD_VALUE: '[id$="secondary_field_value"]' + SECONDARY_FIELD_VALUE: '[id$="secondary_field_value"]', + DUAL_CATEGORY_CHART_CARD: 'dual-category-chart-card' }; static FIELD_SUFFIXES = { @@ -403,7 +404,11 @@ class DualCategoryChartCard { } render(prefix, initialState, block) { - const rootNode = block.container[4] + // from the telepath StructBlock representation, + // 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);