diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 7968830e74..2837c2104f 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -34,7 +34,8 @@ Changelog * Add a keyboard shortcut to easily toggle the visibility of the minimap side panel (Dhruvi Patel) * Add API for extracting preview page content (Sage Abdullah) * Add toggle from grid to list layout for image listings (Joel William) - * Add support for opt-in collapsible `StructBlock`s (Sage Abdullah) + * Make `StructBlock`s collapsible when nested to support block settings (Sage Abdullah) + * Improve `label_format` support for more widget types in StreamField (Sage Abdullah) * Add `form_attrs` support to all StreamField blocks (Sage Abdullah) * Update project template documentation to include testing instructions and include starting test file in template (Aditya (megatrron)) * Add support for `preview_value` and `default` in `Block` meta options as callables for dynamic previews within StreamField (Ziyao Yan, Sage Abdullah) diff --git a/client/src/components/Widget/index.js b/client/src/components/Widget/index.js index 44b620c1fa..02b14c13a8 100644 --- a/client/src/components/Widget/index.js +++ b/client/src/components/Widget/index.js @@ -1,4 +1,5 @@ import { setAttrs } from '../../utils/attrs'; +import { gettext } from '../../utils/gettext'; import { runInlineScripts } from '../../utils/runInlineScripts'; /** @@ -70,14 +71,20 @@ export class BoundWidget { } } + getValueForLabel() { + return this.getValue(); + } + getTextLabel(opts) { - const val = this.getValue(); - if (typeof val !== 'string') return null; + const val = this.getValueForLabel(); + const allowedTypes = ['string', 'number', 'boolean']; + if (!allowedTypes.includes(typeof val)) return null; + const valString = String(val).trim(); const maxLength = opts && opts.maxLength; - if (maxLength && val.length > maxLength) { - return val.substring(0, maxLength - 1) + '…'; + if (maxLength && valString.length > maxLength) { + return valString.substring(0, maxLength - 1) + '…'; } - return val; + return valString; } focus() { @@ -156,6 +163,10 @@ export class BoundCheckboxInput extends BoundWidget { setState(state) { this.input.checked = state; } + + getValueForLabel() { + return this.getValue() ? gettext('Yes') : gettext('No'); + } } export class CheckboxInput extends Widget { @@ -173,6 +184,27 @@ export class BoundRadioSelect { this.selector = `input[name="${name}"]:checked`; } + getValueForLabel() { + const getLabels = (input) => { + const labels = Array.from(input?.labels || []) + .map((label) => label.textContent.trim()) + .filter(Boolean); + return labels.join(', '); + }; + if (this.isMultiple) { + return Array.from(this.element.querySelectorAll(this.selector)) + .map(getLabels) + .join(', '); + } + return getLabels(this.element.querySelector(this.selector)); + } + + getTextLabel() { + // This class does not extend BoundWidget, so we don't have the truncating + // logic without duplicating the code here. Skip it for now. + return this.getValueForLabel(); + } + getValue() { if (this.isMultiple) { return Array.from(this.element.querySelectorAll(this.selector)).map( @@ -217,7 +249,7 @@ export class RadioSelect extends Widget { } export class BoundSelect extends BoundWidget { - getTextLabel() { + getValueForLabel() { return Array.from(this.input.selectedOptions) .map((option) => option.text) .join(', '); diff --git a/client/src/components/Widget/index.test.js b/client/src/components/Widget/index.test.js index c52a0e45e9..2707878ca7 100644 --- a/client/src/components/Widget/index.test.js +++ b/client/src/components/Widget/index.test.js @@ -238,6 +238,20 @@ describe('RadioSelect', () => { ).toBeNull(); }); + test('getTextLabel() returns the text of selected option', () => { + expect(boundWidget.getTextLabel()).toBe('Tea'); + }); + + test('getTextLabel() safely handles input with no labels', () => { + // Disassociate the label from the input by removing the "for" attribute + // and moving the input outside of the label. + const label = document.querySelector('label[for="the-id_0"]'); + label.removeAttribute('for'); + const input = document.querySelector('input[value="tea"]'); + document.body.appendChild(input); + expect(boundWidget.getTextLabel()).toBe(''); + }); + test('focus() focuses the first element', () => { boundWidget.focus(); @@ -299,6 +313,20 @@ describe('RadioSelect for CheckboxSelectMultiple', () => { expect(document.querySelector('input[value="green"]').checked).toBe(true); expect(document.querySelector('input[value="blue"]').checked).toBe(false); }); + + test('getTextLabel() returns the text of selected options', () => { + expect(boundWidget.getTextLabel()).toBe('Red, Blue'); + }); + + test('getTextLabel() safely handles input with no labels', () => { + // Disassociate the label from the input by removing the "for" attribute + // and moving the input outside of the label. + const label = document.querySelector('label[for="the-id_0"]'); + label.removeAttribute('for'); + const input = document.querySelector('input[value="red"]'); + document.body.appendChild(input); + expect(boundWidget.getTextLabel()).toBe('Blue'); + }); }); describe('CheckboxInput', () => { @@ -338,6 +366,12 @@ describe('CheckboxInput', () => { expect(document.querySelector('input[id="id-sugar"]').checked).toBe(true); }); + test('getTextLabel() returns a human-readable value', () => { + expect(boundWidget.getTextLabel()).toBe('Yes'); + boundWidget.setState(false); + expect(boundWidget.getTextLabel()).toBe('No'); + }); + test('focus() focuses the checkbox', () => { boundWidget.focus(); @@ -377,8 +411,9 @@ describe('Select', () => { expect(selectedOptions[0].value).toBe('1'); }); - test('getTextLabel() returns the text of selected option', () => { + test('getTextLabel() returns the truncated text of selected option', () => { expect(boundWidget.getTextLabel()).toBe('Option 1'); + expect(boundWidget.getTextLabel({ maxLength: 6 })).toBe('Optio…'); }); test('getValue() returns the current value', () => { @@ -429,8 +464,9 @@ describe('Select multiple', () => { expect(selectedOptions[1].value).toBe('blue'); }); - test('getTextLabel() returns the text of selected options', () => { + test('getTextLabel() returns the truncated text of selected options', () => { expect(boundWidget.getTextLabel()).toBe('Red, Blue'); + expect(boundWidget.getTextLabel({ maxLength: 6 })).toBe('Red, …'); }); test('getValue() returns the current values', () => { diff --git a/docs/advanced_topics/customization/streamfield_blocks.md b/docs/advanced_topics/customization/streamfield_blocks.md index 3871e5aec0..67f3944cda 100644 --- a/docs/advanced_topics/customization/streamfield_blocks.md +++ b/docs/advanced_topics/customization/streamfield_blocks.md @@ -51,8 +51,10 @@ class SettingsBlock(blocks.StructBlock): class Meta: icon = 'cog' - collapsed = True # This block will be initially collapsed - label_format = "Settings (Theme: {theme})" # The label when collapsed + # This block will be initially collapsed + collapsed = True + # The label when the block is collapsed + label_format = "Settings (Theme: {theme}, Available: {available})" class PersonBlock(blocks.StructBlock): diff --git a/docs/releases/7.1.md b/docs/releases/7.1.md index 02d275c65a..aa7aa25247 100644 --- a/docs/releases/7.1.md +++ b/docs/releases/7.1.md @@ -82,6 +82,7 @@ For more details, see the documentation on [enabling the user bar](headless_user * Add a keyboard shortcut to easily toggle the visibility of the minimap side panel (Dhruvi Patel) * Add API for extracting preview page content (Sage Abdullah) * Add `form_attrs` support to all StreamField blocks (Sage Abdullah) + * Improve `label_format` support for more widget types in StreamField (Sage Abdullah) * Update project template documentation to include testing instructions and include starting test file in template (Aditya (megatrron)) * Add support for `preview_value` and `default` in `Block` meta options as callables for [dynamic previews within StreamField](configuring_block_previews) (Ziyao Yan, Sage Abdullah) * Provide client-side access to the editing form panel structure (Matt Westcott)