tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit bd3476c70053bbcf4b41f84b2630c46f1a3c0532
parent 10945aef958c54af21b95adbbf0cc6875299e801
Author: mimi <nsauermann@mozilla.com>
Date:   Thu, 30 Oct 2025 20:03:55 +0000

Bug 1996319 - [TOS] Enable ContentTiles to render below LinkParagraph and buttons and update styles r=omc-reviewers,emcminn

Adds new property to describe and determine action buttons position: `content.action_buttons_position` with the following valid positions (`after_subtitle`, `after_supporting_content` and `end` (default) in order to render action buttons after legal paragraph but before content tiles.

For backwards compatibility, legacy property `action_buttons_above_content` maps to `after_subtitle`.

And normalizes tiles into a single shape (tiles: { tiles_items: [], container: {style?: {}, header?: {} }) and keeps backwards compatibility with legacy properties (contentTilesContainer and tiles_header)

Differential Revision: https://phabricator.services.mozilla.com/D270016

Diffstat:
Mbrowser/components/aboutwelcome/content-src/components/ContentTiles.jsx | 57++++++++++++++++++++++++++++++++++++++-------------------
Mbrowser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx | 66++++++++++++++++++++++++++++++++++++++----------------------------
Mbrowser/components/aboutwelcome/content-src/lib/aboutwelcome-utils.mjs | 43+++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/aboutwelcome/content/aboutwelcome.bundle.js | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mbrowser/components/aboutwelcome/tests/unit/ContentTiles.test.jsx | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/aboutwelcome/tests/unit/MultiStageAWProton.test.jsx | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 570 insertions(+), 79 deletions(-)

diff --git a/browser/components/aboutwelcome/content-src/components/ContentTiles.jsx b/browser/components/aboutwelcome/content-src/components/ContentTiles.jsx @@ -59,11 +59,18 @@ export const ContentTiles = props => { return null; } + const { tile_items, container } = + AboutWelcomeUtils.normalizeContentTiles(content); + + if (!tile_items.length) { + return null; + } + // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { // Run once when ContentTiles mounts to prefill activeMultiSelect if (!props.activeMultiSelect) { - const tilesArray = Array.isArray(tiles) ? tiles : [tiles]; + const tilesArray = Array.isArray(tile_items) ? tile_items : [tile_items]; tilesArray.forEach((tile, index) => { if (tile.type !== "multiselect" || !tile.data) { @@ -84,7 +91,7 @@ export const ContentTiles = props => { } }); } - }, [tiles]); // eslint-disable-line react-hooks/exhaustive-deps + }, [tile_items]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { /** @@ -334,24 +341,36 @@ export const ContentTiles = props => { }; const renderContentTiles = () => { - if (Array.isArray(tiles)) { - return ( - <div - id="content-tiles-container" - style={AboutWelcomeUtils.getValidStyle( - content?.contentTilesContainer?.style, - CONTAINER_STYLES - )} - > - {tiles.map((tile, index) => renderContentTile(tile, index))} - </div> - ); + const hasHeader = !!container?.header; + const hasContainerStyle = !!Object.keys(container?.style || {}).length; + + // Legacy rule: tiles as a single object renders without a container. + // Arrays (even length 1) render inside a container. + // Normalize helper will detect original input shape (object vs array) before normalizing to preserve intent. + const isArrayInput = Array.isArray(content.tiles); + if ( + !isArrayInput && + tile_items.length === 1 && + !hasHeader && + !hasContainerStyle + ) { + return renderContentTile(tile_items[0], 0); } - // If tiles is not an array render the tile alone without a container - return renderContentTile(tiles, 0); + + return ( + <div + id="content-tiles-container" + style={AboutWelcomeUtils.getValidStyle( + container?.style, + CONTAINER_STYLES + )} + > + {tile_items.map((tile, index) => renderContentTile(tile, index))} + </div> + ); }; - if (content.tiles_header) { + if (container?.header) { return ( <React.Fragment> <button @@ -360,7 +379,7 @@ export const ContentTiles = props => { aria-expanded={tilesHeaderExpanded} aria-controls={`content-tiles-container`} > - <Localized text={content.tiles_header.title}> + <Localized text={container.header?.title}> <span className="header-title" /> </Localized> <div className="arrow-icon"></div> @@ -369,5 +388,5 @@ export const ContentTiles = props => { </React.Fragment> ); } - return renderContentTiles(tiles); + return renderContentTiles(tile_items); }; diff --git a/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx b/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx @@ -552,6 +552,7 @@ export class ProtonScreen extends React.PureComponent { </div> ); } + getCombinedInnerStyles(content, isWideScreen) { const CONFIGURABLE_STYLES = [ "overflow", @@ -580,6 +581,39 @@ export class ProtonScreen extends React.PureComponent { }; } + getActionButtonsPosition(content) { + const VALID_POSITIONS = [ + "after_subtitle", + "after_supporting_content", + "end", + ]; + + if (VALID_POSITIONS.includes(content.action_buttons_position)) { + return content.action_buttons_position; + } + // Legacy mapping + if (content.action_buttons_above_content) { + return "after_subtitle"; + } + // Default + return "end"; + } + + renderActionButtons(position, content) { + return this.getActionButtonsPosition(content) === position ? ( + <ProtonScreenActionButtons + content={content} + isRtamo={this.props.isRtamo} + installedAddons={this.props.installedAddons} + addonId={this.props.addonId} + addonName={this.props.addonName} + addonType={this.props.addonType} + handleAction={this.props.handleAction} + activeMultiSelect={this.props.activeMultiSelect} + /> + ) : null; + } + // eslint-disable-next-line complexity render() { const { @@ -616,7 +650,6 @@ export class ProtonScreen extends React.PureComponent { const isEmbeddedMigration = content.tiles?.type === "migration-wizard"; const isSystemPromptStyleSpotlight = content.isSystemPromptStyleSpotlight === true; - const combinedStyles = this.getCombinedInnerStyles(content, isWideScreen); return ( @@ -695,7 +728,6 @@ export class ProtonScreen extends React.PureComponent { {content.logo && !content.fullscreen ? this.renderPicture(content.logo) : null} - {isRtamo && !content.fullscreen ? this.renderRTAMOIcon( addonType, @@ -703,7 +735,6 @@ export class ProtonScreen extends React.PureComponent { this.props.addonIconURL ) : null} - <div className="main-content-inner" style={combinedStyles}> {content.logo && content.fullscreen ? this.renderPicture(content.logo) @@ -738,18 +769,7 @@ export class ProtonScreen extends React.PureComponent { /> </Localized> ) : null} - {content.action_buttons_above_content && ( - <ProtonScreenActionButtons - content={content} - isRtamo={this.props.isRtamo} - installedAddons={this.props.installedAddons} - addonId={this.props.addonId} - addonName={this.props.addonName} - addonType={this.props.addonType} - handleAction={this.props.handleAction} - activeMultiSelect={this.props.activeMultiSelect} - /> - )} + {this.renderActionButtons("after_subtitle", content)} {content.cta_paragraph ? ( <CTAParagraph content={content.cta_paragraph} @@ -764,26 +784,16 @@ export class ProtonScreen extends React.PureComponent { handleAction={this.props.handleAction} /> ) : null} - <ContentTiles {...this.props} /> {this.renderLanguageSwitcher()} {content.above_button_content ? this.renderOrderedContent(content.above_button_content) : null} + {this.renderActionButtons("after_supporting_content", content)} + <ContentTiles {...this.props} /> {!hideStepsIndicator && aboveButtonStepsIndicator ? this.renderStepsIndicator() : null} - {!content.action_buttons_above_content && ( - <ProtonScreenActionButtons - content={content} - isRtamo={this.props.isRtamo} - installedAddons={this.props.installedAddons} - addonId={this.props.addonId} - addonName={this.props.addonName} - addonType={this.props.addonType} - handleAction={this.props.handleAction} - activeMultiSelect={this.props.activeMultiSelect} - /> - )} + {this.renderActionButtons("end", content)} { /* Fullscreen dot-style step indicator should sit inside the main inner content to share its padding, which will be diff --git a/browser/components/aboutwelcome/content-src/lib/aboutwelcome-utils.mjs b/browser/components/aboutwelcome/content-src/lib/aboutwelcome-utils.mjs @@ -100,4 +100,47 @@ export const AboutWelcomeUtils = { true ); }, + + /** + * Normalize content.tiles into a single shape: + * tiles: { tile_items: Array<Tile> | Tile, container?: { style?: Object, header?: Object } } + * + * Supports legacy tiles array and single tile object and consumes + * legacy container `content.contentTilesContainer.style` and + * legacy header `content.tiles_header` properties. + */ + normalizeContentTiles(content) { + const { tiles } = content; + const legacyContainer = content?.contentTilesContainer; + const legacyHeader = content?.tiles_header; + + // Prefer tiles.container styles, fallback to legacy style, default {} + const style = tiles?.container?.style ?? legacyContainer?.style ?? {}; + + // Prefer tiles.container.header, fall back to legacy header + const header = tiles?.container?.header ?? legacyHeader; + + let items; + // New shape + if (tiles?.tile_items !== undefined) { + items = Array.isArray(tiles.tile_items) + ? tiles.tile_items + : [tiles.tile_items]; + } + // Legacy tiles array + else if (Array.isArray(tiles)) { + items = tiles; + } + // Legacy tiles object + else if (tiles && typeof tiles === "object" && tiles.type) { + items = [tiles]; + } else { + items = []; + } + + // Omit header when absent + const container = header ? { style, header } : { style }; + + return { tile_items: items, container }; + }, }; diff --git a/browser/components/aboutwelcome/content/aboutwelcome.bundle.js b/browser/components/aboutwelcome/content/aboutwelcome.bundle.js @@ -129,6 +129,49 @@ const AboutWelcomeUtils = { true ); }, + + /** + * Normalize content.tiles into a single shape: + * tiles: { tile_items: Array<Tile> | Tile, container?: { style?: Object, header?: Object } } + * + * Supports legacy tiles array and single tile object and consumes + * legacy container `content.contentTilesContainer.style` and + * legacy header `content.tiles_header` properties. + */ + normalizeContentTiles(content) { + const { tiles } = content; + const legacyContainer = content?.contentTilesContainer; + const legacyHeader = content?.tiles_header; + + // Prefer tiles.container styles, fallback to legacy style, default {} + const style = tiles?.container?.style ?? legacyContainer?.style ?? {}; + + // Prefer tiles.container.header, fall back to legacy header + const header = tiles?.container?.header ?? legacyHeader; + + let items; + // New shape + if (tiles?.tile_items !== undefined) { + items = Array.isArray(tiles.tile_items) + ? tiles.tile_items + : [tiles.tile_items]; + } + // Legacy tiles array + else if (Array.isArray(tiles)) { + items = tiles; + } + // Legacy tiles object + else if (tiles && typeof tiles === "object" && tiles.type) { + items = [tiles]; + } else { + items = []; + } + + // Omit header when absent + const container = header ? { style, header } : { style }; + + return { tile_items: items, container }; + }, }; @@ -1516,6 +1559,30 @@ class ProtonScreen extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCom justifyContent: content.split_content_justify_content }; } + getActionButtonsPosition(content) { + const VALID_POSITIONS = ["after_subtitle", "after_supporting_content", "end"]; + if (VALID_POSITIONS.includes(content.action_buttons_position)) { + return content.action_buttons_position; + } + // Legacy mapping + if (content.action_buttons_above_content) { + return "after_subtitle"; + } + // Default + return "end"; + } + renderActionButtons(position, content) { + return this.getActionButtonsPosition(content) === position ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(ProtonScreenActionButtons, { + content: content, + isRtamo: this.props.isRtamo, + installedAddons: this.props.installedAddons, + addonId: this.props.addonId, + addonName: this.props.addonName, + addonType: this.props.addonType, + handleAction: this.props.handleAction, + activeMultiSelect: this.props.activeMultiSelect + }) : null; + } // eslint-disable-next-line complexity render() { @@ -1588,31 +1655,13 @@ class ProtonScreen extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCom }), "aria-flowto": this.props.messageId?.includes("FEATURE_TOUR") ? "steps" : "", id: "mainContentSubheader" - })) : null, content.action_buttons_above_content && /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(ProtonScreenActionButtons, { - content: content, - isRtamo: this.props.isRtamo, - installedAddons: this.props.installedAddons, - addonId: this.props.addonId, - addonName: this.props.addonName, - addonType: this.props.addonType, - handleAction: this.props.handleAction, - activeMultiSelect: this.props.activeMultiSelect - }), content.cta_paragraph ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_CTAParagraph__WEBPACK_IMPORTED_MODULE_5__.CTAParagraph, { + })) : null, this.renderActionButtons("after_subtitle", content), content.cta_paragraph ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_CTAParagraph__WEBPACK_IMPORTED_MODULE_5__.CTAParagraph, { content: content.cta_paragraph, handleAction: this.props.handleAction }) : null) : null, content.video_container ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_OnboardingVideo__WEBPACK_IMPORTED_MODULE_7__.OnboardingVideo, { content: content.video_container, handleAction: this.props.handleAction - }) : null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_ContentTiles__WEBPACK_IMPORTED_MODULE_10__.ContentTiles, this.props), this.renderLanguageSwitcher(), content.above_button_content ? this.renderOrderedContent(content.above_button_content) : null, !hideStepsIndicator && aboveButtonStepsIndicator ? this.renderStepsIndicator() : null, !content.action_buttons_above_content && /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(ProtonScreenActionButtons, { - content: content, - isRtamo: this.props.isRtamo, - installedAddons: this.props.installedAddons, - addonId: this.props.addonId, - addonName: this.props.addonName, - addonType: this.props.addonType, - handleAction: this.props.handleAction, - activeMultiSelect: this.props.activeMultiSelect - }), + }) : null, this.renderLanguageSwitcher(), content.above_button_content ? this.renderOrderedContent(content.above_button_content) : null, this.renderActionButtons("after_supporting_content", content), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_ContentTiles__WEBPACK_IMPORTED_MODULE_10__.ContentTiles, this.props), !hideStepsIndicator && aboveButtonStepsIndicator ? this.renderStepsIndicator() : null, this.renderActionButtons("end", content), /* Fullscreen dot-style step indicator should sit inside the main inner content to share its padding, which will be configurable with Bug 1956042 */ @@ -2337,12 +2386,19 @@ const ContentTiles = props => { if (!tiles) { return null; } + const { + tile_items, + container + } = _lib_aboutwelcome_utils_mjs__WEBPACK_IMPORTED_MODULE_11__.AboutWelcomeUtils.normalizeContentTiles(content); + if (!tile_items.length) { + return null; + } // eslint-disable-next-line react-hooks/rules-of-hooks (0,react__WEBPACK_IMPORTED_MODULE_0__.useEffect)(() => { // Run once when ContentTiles mounts to prefill activeMultiSelect if (!props.activeMultiSelect) { - const tilesArray = Array.isArray(tiles) ? tiles : [tiles]; + const tilesArray = Array.isArray(tile_items) ? tile_items : [tile_items]; tilesArray.forEach((tile, index) => { if (tile.type !== "multiselect" || !tile.data) { return; @@ -2362,7 +2418,7 @@ const ContentTiles = props => { } }); } - }, [tiles]); // eslint-disable-line react-hooks/exhaustive-deps + }, [tile_items]); // eslint-disable-line react-hooks/exhaustive-deps (0,react__WEBPACK_IMPORTED_MODULE_0__.useEffect)(() => { /** @@ -2555,30 +2611,36 @@ const ContentTiles = props => { })) : null); }; const renderContentTiles = () => { - if (Array.isArray(tiles)) { - return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", { - id: "content-tiles-container", - style: _lib_aboutwelcome_utils_mjs__WEBPACK_IMPORTED_MODULE_11__.AboutWelcomeUtils.getValidStyle(content?.contentTilesContainer?.style, CONTAINER_STYLES) - }, tiles.map((tile, index) => renderContentTile(tile, index))); + const hasHeader = !!container?.header; + const hasContainerStyle = !!Object.keys(container?.style || {}).length; + + // Legacy rule: tiles as a single object renders without a container. + // Arrays (even length 1) render inside a container. + // Normalize helper will detect original input shape (object vs array) before normalizing to preserve intent. + const isArrayInput = Array.isArray(content.tiles); + if (!isArrayInput && tile_items.length === 1 && !hasHeader && !hasContainerStyle) { + return renderContentTile(tile_items[0], 0); } - // If tiles is not an array render the tile alone without a container - return renderContentTile(tiles, 0); + return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", { + id: "content-tiles-container", + style: _lib_aboutwelcome_utils_mjs__WEBPACK_IMPORTED_MODULE_11__.AboutWelcomeUtils.getValidStyle(container?.style, CONTAINER_STYLES) + }, tile_items.map((tile, index) => renderContentTile(tile, index))); }; - if (content.tiles_header) { + if (container?.header) { return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement((react__WEBPACK_IMPORTED_MODULE_0___default().Fragment), null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("button", { className: "content-tiles-header secondary", onClick: toggleTiles, "aria-expanded": tilesHeaderExpanded, "aria-controls": `content-tiles-container` }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__.Localized, { - text: content.tiles_header.title + text: container.header?.title }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("span", { className: "header-title" })), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", { className: "arrow-icon" })), tilesHeaderExpanded && renderContentTiles()); } - return renderContentTiles(tiles); + return renderContentTiles(tile_items); }; /***/ }), diff --git a/browser/components/aboutwelcome/tests/unit/ContentTiles.test.jsx b/browser/components/aboutwelcome/tests/unit/ContentTiles.test.jsx @@ -1033,4 +1033,256 @@ describe("ContentTiles component", () => { ); mountedWrapper.unmount(); }); + + it("renders a single tile without container when there is no header or container style", () => { + const SINGLE_TILE_NO_CONTAINER = { + tiles: { + tile_items: { + type: "mobile_downloads", + data: { email: { link_text: "Email!" } }, + }, + container: {}, + }, + }; + + const mounted = mount( + <ContentTiles + content={SINGLE_TILE_NO_CONTAINER} + handleAction={() => {}} + activeMultiSelect={null} + setActiveMultiSelect={setActiveMultiSelect} + /> + ); + + assert.isFalse( + mounted.find("#content-tiles-container").exists(), + "should not render container wrapper" + ); + assert.equal(mounted.find(".content-tile").length, 1); + + mounted.unmount(); + }); + + it("renders container when single tile has container.style", () => { + const SINGLE_TILE_WITH_STYLE = { + tiles: { + container: { style: { marginBlock: "40px" } }, + tile_items: { + type: "mobile_downloads", + data: { email: { link_text: "Email!!" } }, + }, + }, + }; + + const mounted = mount( + <ContentTiles + content={SINGLE_TILE_WITH_STYLE} + handleAction={() => {}} + activeMultiSelect={null} + setActiveMultiSelect={setActiveMultiSelect} + /> + ); + + const container = mounted.find("#content-tiles-container"); + assert.isTrue(container.exists(), "container should be rendered"); + + const el = container.getDOMNode(); + assert.match( + el.style.cssText, + "margin-block: 40px", + "container style applied" + ); + + mounted.unmount(); + }); + + it("renders container when single tile has header", () => { + const SINGLE_TILE_WITH_HEADER = { + tiles: { + container: { + header: { title: "Toggle tiles header" }, + }, + tile_items: { + type: "mobile_downloads", + data: { email: { link_text: "Email!!" } }, + }, + }, + }; + + const mounted = mount( + <ContentTiles + content={SINGLE_TILE_WITH_HEADER} + handleAction={() => {}} + activeMultiSelect={null} + setActiveMultiSelect={setActiveMultiSelect} + /> + ); + + const headerBtn = mounted.find(".content-tiles-header"); + assert.isTrue(headerBtn.exists(), "header button should render"); + + assert.isFalse( + mounted.find("#content-tiles-container").exists(), + "container should be hidden initially" + ); + + headerBtn.simulate("click"); + assert.isTrue( + mounted.find("#content-tiles-container").exists(), + "container should render after toggle" + ); + + mounted.unmount(); + }); + + it("supports legacy content.tiles_header as container header", () => { + const LEGACY_HEADER_SINGLE_TILE = { + tiles_header: { title: "Legacy tile header" }, + tiles: { + type: "multiselect", + header: { + title: "Multiselect header", + }, + data: [{ id: "option1", defaultValue: true }], + }, + }; + + const mounted = mount( + <ContentTiles + content={LEGACY_HEADER_SINGLE_TILE} + handleAction={() => {}} + activeMultiSelect={null} + setActiveMultiSelect={setActiveMultiSelect} + /> + ); + + const headerBtn = mounted.find(".content-tiles-header"); + assert.isTrue(headerBtn.exists(), "legacy header button should render"); + + assert.isFalse( + mounted.find("#content-tiles-container").exists(), + "container hidden initially" + ); + + headerBtn.simulate("click"); + assert.isTrue( + mounted.find("#content-tiles-container").exists(), + "container visible after toggle" + ); + + mounted.unmount(); + }); + + it("supports legacy content.contentTilesContainer.style as container style", () => { + const LEGACY_CONTAINER_STYLE = { + contentTilesContainer: { style: { marginBlock: "6px" } }, + tiles: [ + { + type: "multiselect", + header: { + title: "Multiselect Header", + }, + data: [{ id: "option1", defaultValue: true }], + }, + ], + }; + + const mounted = mount( + <ContentTiles + content={LEGACY_CONTAINER_STYLE} + handleAction={() => {}} + activeMultiSelect={null} + setActiveMultiSelect={setActiveMultiSelect} + setScreenMultiSelects={sandbox.stub()} + /> + ); + + const container = mounted.find("#content-tiles-container"); + assert.isTrue(container.exists(), "container should be rendered"); + + const el = container.getDOMNode(); + assert.match( + el.style.cssText, + "margin-block: 6px", + "legacy container style applied" + ); + + mounted.unmount(); + }); + + it("renders header toggle when only header.subtitle is provided", () => { + const HEADER_WITH_SUBTITLE_ONLY = { + tiles: { + container: { + header: { subtitle: "Only a subtitle" }, + }, + tile_items: [ + { + type: "mobile_downloads", + data: { email: { link_text: "Email yourself a link" } }, + }, + ], + }, + }; + + const mounted = mount( + <ContentTiles + content={HEADER_WITH_SUBTITLE_ONLY} + handleAction={() => {}} + activeMultiSelect={null} + setActiveMultiSelect={setActiveMultiSelect} + /> + ); + + const headerBtn = mounted.find(".content-tiles-header"); + assert.isTrue( + headerBtn.exists(), + "header toggle renders with subtitle only" + ); + + headerBtn.simulate("click"); + assert.isTrue( + mounted.find("#content-tiles-container").exists(), + "container renders after toggle" + ); + + mounted.unmount(); + }); + + it("multiple tiles render inside container and count matches", () => { + const NEW_SHAPE_MULTIPLE = { + tiles: { + tile_items: [ + { + type: "mobile_downloads", + data: { email: { link_text: "Email" } }, + }, + { + type: "multiselect", + header: { + title: "Multiselect header", + }, + data: [{ id: "option1", defaultValue: true }], + }, + ], + }, + }; + + const mounted = mount( + <ContentTiles + content={NEW_SHAPE_MULTIPLE} + handleAction={() => {}} + activeMultiSelect={null} + setActiveMultiSelect={setActiveMultiSelect} + /> + ); + + assert.isTrue( + mounted.find("#content-tiles-container").exists(), + "container present" + ); + assert.equal(mounted.find(".content-tile").length, 2, "renders both tiles"); + + mounted.unmount(); + }); }); diff --git a/browser/components/aboutwelcome/tests/unit/MultiStageAWProton.test.jsx b/browser/components/aboutwelcome/tests/unit/MultiStageAWProton.test.jsx @@ -825,6 +825,111 @@ describe("MultiStageAboutWelcomeProton module", () => { "Second child is ProtonScreenActionButtons" ); }); + + it("should render action buttons after tiles by default when no position is configured", async () => { + const SCREEN_PROPS = { + content: { + title: "test title", + position: "center", + primary_button: { label: "Confirm and continue" }, + tiles_header: { title: "Title" }, + tiles: { + type: "multiselect", + data: [ + { id: "checkbox-1", label: "Option 1" }, + { id: "checkbox-2", label: "Option 2" }, + ], + }, + }, + }; + + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + + const mainInner = wrapper.find(".main-content-inner"); + const lastChild = mainInner.children().last(); + assert.strictEqual( + lastChild.type(), + ProtonScreenActionButtons, + "Last child is ProtonScreenActionButtons" + ); + }); + + it("should render action buttons after subtitle when configured", async () => { + const SCREEN_PROPS = { + content: { + title: "Test title", + subtitle: "Test subtitle", + position: "center", + action_buttons_position: "after_subtitle", + primary_button: { label: "Get started" }, + }, + }; + + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists(), "Screen renders"); + + // Find the welcome text container + const welcomeTextEl = wrapper.find(".welcome-text"); + assert.isTrue(welcomeTextEl.exists(), "Welcome text exists"); + + const subtitleEl = welcomeTextEl.find("h2"); + assert.ok(subtitleEl.exists(), "Subtitle exists"); + const nextEl = subtitleEl.getDOMNode().nextElementSibling; + assert.isTrue( + nextEl.classList.contains("action-buttons"), + "Next element is action-buttons" + ); + }); + + it("should render action buttons after supporting content but before tiles when configured", async () => { + const SCREEN_PROPS = { + content: { + title: "Welcome to Firefox", + position: "center", + action_buttons_position: "after_supporting_content", + above_button_content: [ + { + type: "text", + text: { string_id: "tou-existing-user-spotlight-body" }, + font_styles: "legal", + link_keys: ["terms-of-use", "privacy-notice", "learn-more"], + }, + ], + primary_button: { label: "Confirm and continue" }, + tiles_header: { + title: { + string_id: "preonboarding-manage-data-header-button-title", + }, + }, + tiles: { + type: "multiselect", + data: [ + { id: "checkbox-1", label: "Interaction data" }, + { id: "checkbox-2", label: "Crash data" }, + ], + }, + }, + }; + + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + + const legalParagraphEl = wrapper.find(".legal-paragraph"); + assert.equal(legalParagraphEl.exists(), true, "Legal paragraph renders"); + + const nextEl = legalParagraphEl.getDOMNode().nextElementSibling; + assert.isTrue( + nextEl.classList.contains("action-buttons"), + "Next element after legal paragraph should be action buttons" + ); + + const afterButtonsEl = nextEl.nextElementSibling; + assert.isTrue( + afterButtonsEl.classList.contains("content-tiles-header"), + "Next element after action buttons should be content-tiles-header" + ); + }); }); describe("AboutWelcomeDefaults for proton", () => {