diff --git a/.distignore b/.distignore index 27b54024f7..81466f2425 100644 --- a/.distignore +++ b/.distignore @@ -45,6 +45,8 @@ header-footer-grid/package.json header-footer-grid/package-lock.json header-footer-grid/rollup.config.js vendor/codeinwp/ti-onboarding/assets/vue +vendor/codeinwp/themeisle-sdk/CHANGELOG.md +vendor/composer/installed.json vendor/codeinwp/ti-about-page/assets/scss vendor/codeinwp/ti-about-page/assets/scss vendor/codeinwp/ti-onboarding/assets/scss diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index c3ffa1604f..f88235b7db 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -128,6 +128,8 @@ jobs: envs: "sample-data" # - specs: "woo-visual-regression" # envs: "woo-sample" + - specs: "editor" + envs: "sample-data" runs-on: ubuntu-22.04 env: ZIP_URL: "https://verti-artifacts.s3.amazonaws.com/${{ github.event.pull_request.base.repo.name }}-${{ needs.dev-zip.outputs.branch-name }}-${{ needs.dev-zip.outputs.git-sha-8 }}/neve.zip" @@ -150,5 +152,7 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: + name: traces-${{ matrix.specs }}-${{ matrix.envs }} path: ./test-results/** + if-no-files-found: ignore retention-days: 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4253046f77..1c5b33a033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,7 +77,7 @@ New Features - Integrated installation of Login Customizer, Cookie Notice, Duplicate Page, and Custom Fonts/Scripts modules - New Maintenance and Coming Soon custom layouts [PRO] - Admin Dashboard Customizer module to personalize the WordPress admin experience (Menu, Admin Bar, Custom Pages) [Agency] -- Included premium WP Landing Kit wordpress plugin [Agency] +- Included premium WP Landing Kit WordPress plugin [Agency] Enhancements diff --git a/assets/apps/metabox/src/components/MetaFieldsManager.js b/assets/apps/metabox/src/components/MetaFieldsManager.js index e389a319c1..3b126beb91 100644 --- a/assets/apps/metabox/src/components/MetaFieldsManager.js +++ b/assets/apps/metabox/src/components/MetaFieldsManager.js @@ -126,17 +126,39 @@ class MetaFieldsManager extends Component { }); } - addCSSToHead(css, styleID = 'neve-meta-editor-style') { - const head = document.head; - const hasStyle = document.getElementById(styleID); - if (hasStyle) { - hasStyle.innerHTML = css; - return false; + /** + * Documents the editor styles need to be added to. + * + * Since the block editor renders inside an iframe, styles added to the + * top document alone don't reach the content. Older setups without the + * iframe keep working through the top document. + * + * @return {Document[]} Target documents. + */ + getStyleTargets() { + const targets = [document]; + const canvas = document.querySelector('iframe[name="editor-canvas"]'); + const canvasDocument = canvas && canvas.contentDocument; + + if (canvasDocument && canvasDocument.head) { + targets.push(canvasDocument); } - const style = document.createElement('style'); - style.setAttribute('id', styleID); - style.innerHTML = css; - head.appendChild(style); + + return targets; + } + + addCSSToHead(css, styleID = 'neve-meta-editor-style') { + this.getStyleTargets().forEach((doc) => { + const hasStyle = doc.getElementById(styleID); + if (hasStyle) { + hasStyle.innerHTML = css; + return; + } + const style = doc.createElement('style'); + style.setAttribute('id', styleID); + style.innerHTML = css; + doc.head.appendChild(style); + }); } updateTitleVisibility() { diff --git a/assets/js/src/frontend/navigation.js b/assets/js/src/frontend/navigation.js index 6c1fa88ec2..7d4671c807 100644 --- a/assets/js/src/frontend/navigation.js +++ b/assets/js/src/frontend/navigation.js @@ -1,4 +1,4 @@ -/* global NeveProperties menuCalcEvent CustomEvent */ +/* global menuCalcEvent CustomEvent */ /* jshint esversion: 6 */ import { toggleClass, @@ -29,39 +29,49 @@ export const initNavigation = () => { * Reposition drop downs in case they go off screen. */ export const repositionDropdowns = () => { - const { isRTL } = NeveProperties; const dropDowns = document.querySelectorAll( '.sub-menu, .minimal .nv-nav-search' ); - if (dropDowns.length === 0) return; + if (!dropDowns.length) return; const windowWidth = window.innerWidth; + // How far a dropdown runs past the viewport: negative past the left edge, + // positive past the right edge, zero when it fits. + const getOverflow = (bounding) => { + if (bounding.left < 0) { + return bounding.left; + } + + return bounding.right > windowWidth ? bounding.right - windowWidth : 0; + }; + dropDowns.forEach((dropDown) => { - let bounding = dropDown.getBoundingClientRect(), - rightDist = bounding.left; + const style = dropDown.style; - if (rightDist < 0) { - dropDown.style.right = isRTL ? '-100%' : 'auto'; - dropDown.style.left = isRTL ? 'auto' : 0; - } + // Drop what an earlier pass applied, so the dropdown is measured where + // the stylesheet puts it and stale offsets do not pile up. + style.right = style.left = style.transform = ''; - if (rightDist + bounding.width >= windowWidth) { - dropDown.style.right = isRTL ? 0 : '100%'; - dropDown.style.left = 'auto'; + let overflow = getOverflow(dropDown.getBoundingClientRect()); + + if (!overflow) { + return; } - // Recalculate bounding after we've made adjustments. - bounding = dropDown.getBoundingClientRect(); - rightDist = bounding.left; + const edge = dropDown.matches('.sub-menu .sub-menu') ? '100%' : '0px'; + + style.right = overflow < 0 ? 'auto' : edge; + style.left = overflow < 0 ? edge : 'auto'; + + // Wider than the space on the side it opens towards, offset it to fit. + overflow = getOverflow(dropDown.getBoundingClientRect()); - if (rightDist < 0 || rightDist + bounding.width >= windowWidth) { - // Calculate how much should we offset the dropdown to make it fit. - dropDown.style.transform = + if (overflow) { + style.transform = 'translateX(' + - (isRTL ? '-' : '') + - (Math.abs(rightDist) + 20) + + (overflow < 0 ? 20 - overflow : -overflow - 20) + 'px)'; } }); diff --git a/bin/envs/cli-setup.sh b/bin/envs/cli-setup.sh index e1a98de0c5..4d1c342cd2 100755 --- a/bin/envs/cli-setup.sh +++ b/bin/envs/cli-setup.sh @@ -21,6 +21,13 @@ init_environment(){ echo "Installing Neve theme from $NEVE_LOCATION" wp --allow-root theme install --activate $NEVE_LOCATION wp --allow-root option update fresh_site 0 + + # Activating Neve remaps widgets by sidebar id, and 'blog-sidebar' does not + # match the previous theme's, so the default widgets can be left unassigned. + if [ "$(wp --allow-root widget list blog-sidebar --format=count)" = "0" ]; then + echo "Populating blog-sidebar" + wp --allow-root widget add block blog-sidebar --content='' + fi echo "Installing Theme API Plugin" wp --allow-root plugin install https://github.com/codeinwp/wp-thememods-api/archive/refs/heads/main.zip --force --activate } diff --git a/bin/ss-pages.test.stub b/bin/ss-pages.test.stub index c761c2303a..16477c07c3 100755 --- a/bin/ss-pages.test.stub +++ b/bin/ss-pages.test.stub @@ -4,7 +4,7 @@ describe( 'Starter Sites VR - {{site}}', () => { let frontpage = "{{site}}" cy.visit(frontpage ); cy.captureDocument(); - cy.get( '#nv-primary-navigation-main' ).then( $headerMenu => { + cy.get( '[id^="nv-primary-navigation"]' ).then( $headerMenu => { [ ...$headerMenu.find( '.menu-item a' ) ].forEach( $url => { let url = $url.href; if(url.includes("#")){ @@ -13,8 +13,11 @@ describe( 'Starter Sites VR - {{site}}', () => { if(frontpage.replace(/\/*$/, "") === url.replace(/\/*$/, "")){ return; } + if(pages.includes(url)){ + return; + } - pages.push( $url.href ); + pages.push( url ); } ); } ); } ); diff --git a/composer.lock b/composer.lock index 2c18c51427..f8fe4380bf 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.58", + "version": "3.3.60", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82" + "reference": "9381b29cb9b315657b8cbb192163be64b24995a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/d6807c0b7308e323bd77cced667dee3f2d5e6a82", - "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/9381b29cb9b315657b8cbb192163be64b24995a8", + "reference": "9381b29cb9b315657b8cbb192163be64b24995a8", "shasum": "" }, "require-dev": { @@ -43,9 +43,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.58" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.60" }, - "time": "2026-07-29T08:38:52+00:00" + "time": "2026-08-24T08:40:23+00:00" }, { "name": "wptt/webfont-loader", diff --git a/e2e-tests/fixtures/customizer/hfg/footer-menu-both-devices-setup.json b/e2e-tests/fixtures/customizer/hfg/footer-menu-both-devices-setup.json new file mode 100644 index 0000000000..7845b249f7 --- /dev/null +++ b/e2e-tests/fixtures/customizer/hfg/footer-menu-both-devices-setup.json @@ -0,0 +1,4 @@ +{ + "hfg_footer_layout_v2": "{\"desktop\":{\"top\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"main\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"bottom\":{\"left\":[],\"c-left\":[],\"center\":[{\"id\":\"footer-menu\"}],\"c-right\":[],\"right\":[]}},\"mobile\":{\"top\":{\"left\":[],\"c-left\":[],\"center\":[{\"id\":\"footer-menu\"}],\"c-right\":[],\"right\":[]},\"main\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"bottom\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]}}}", + "nav_menu_locations[footer]": 177 +} diff --git a/e2e-tests/fixtures/customizer/hfg/primary-menu-both-devices-setup.json b/e2e-tests/fixtures/customizer/hfg/primary-menu-both-devices-setup.json new file mode 100644 index 0000000000..2ea0e0f511 --- /dev/null +++ b/e2e-tests/fixtures/customizer/hfg/primary-menu-both-devices-setup.json @@ -0,0 +1,4 @@ +{ + "hfg_header_layout_v2": "{\"desktop\":{\"top\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"main\":{\"left\":[{\"id\":\"logo\"}],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[{\"id\":\"primary-menu\"}]},\"bottom\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]}},\"mobile\":{\"top\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"main\":{\"left\":[{\"id\":\"logo\"}],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[{\"id\":\"primary-menu\"},{\"id\":\"nav-icon\"}]},\"bottom\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"sidebar\":[{\"id\":\"primary-menu\"}]}}", + "nav_menu_locations[primary]": 177 +} diff --git a/e2e-tests/fixtures/customizer/hfg/submenu-viewport-setup.json b/e2e-tests/fixtures/customizer/hfg/submenu-viewport-setup.json new file mode 100644 index 0000000000..25f2047cab --- /dev/null +++ b/e2e-tests/fixtures/customizer/hfg/submenu-viewport-setup.json @@ -0,0 +1,7 @@ +{ + "neve_migrated_hfg_colors": true, + "ti_prev_theme": "twentytwentyone", + "neve_ran_migrations": true, + "neve_container_width": "{\"mobile\":748,\"tablet\":992,\"desktop\":2000,\"suffix\":{\"mobile\":\"px\",\"tablet\":\"px\",\"desktop\":\"px\"}}", + "hfg_header_layout_v2": "{\"desktop\":{\"top\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"main\":{\"left\":[{\"id\":\"logo\"}],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[{\"id\":\"primary-menu\"}]},\"bottom\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]}},\"mobile\":{\"top\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"main\":{\"left\":[{\"id\":\"logo\"}],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[{\"id\":\"nav-icon\"}]},\"bottom\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"sidebar\":[{\"id\":\"primary-menu\"}]}}" +} diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index a60069d819..4dfdc0a77e 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -1,11 +1,18 @@ import { defineConfig, devices } from '@playwright/test'; +// Plain `|| fallback` would discard an explicit 0. +const envInt = (name: string, fallback: number) => { + const parsed = parseInt(process.env[name] || '', 10); + return Number.isNaN(parsed) ? fallback : parsed; +}; + export default defineConfig({ reporter: process.env.CI ? 'github' : 'list', forbidOnly: !!process.env.CI, - workers: process.env.CI ? 6 : undefined, - retries: 0, - timeout: parseInt(process.env.TIMEOUT || '', 10) || 150_000, // Defaults to 100 seconds. + // Same values locally and in CI, so a race reproduces in both. + workers: envInt('PW_WORKERS', 2), + retries: envInt('PW_RETRIES', 2), + timeout: envInt('TIMEOUT', 150_000), // Defaults to 100 seconds. fullyParallel: true, projects: [ // Setup project @@ -26,5 +33,7 @@ export default defineConfig({ headless: true, ignoreHTTPSErrors: true, trace: 'retain-on-failure', + actionTimeout: 20_000, + navigationTimeout: 45_000, }, }); diff --git a/e2e-tests/specs/accessibility/aria.spec.ts b/e2e-tests/specs/accessibility/aria.spec.ts index f146bea5f3..60d3a10bae 100644 --- a/e2e-tests/specs/accessibility/aria.spec.ts +++ b/e2e-tests/specs/accessibility/aria.spec.ts @@ -16,9 +16,12 @@ const runMenuARIATest = (deviceType = 'mobile') => { ).toHaveAttribute('aria-expanded', 'true'); // Close the menu from the overlay. Check ARIA attribute for expanded state is false when menu is closed. + // The overlay spans the viewport and the open sidebar covers its centre, so + // a real click there lands on a menu link and navigates away. Dispatching + // hits the same handler without depending on which side the sidebar is on. await page .locator('.header-menu-sidebar-overlay') - .click({ force: true }); + .dispatchEvent('click'); await expect( page.getByRole('button', { name: 'Navigation Menu' }) ).toHaveAttribute('aria-expanded', 'false'); diff --git a/e2e-tests/specs/admin/tpc-notice-install.spec.ts b/e2e-tests/specs/admin/tpc-notice-install.spec.ts index 74e29eb3fa..178cf4ddc9 100644 --- a/e2e-tests/specs/admin/tpc-notice-install.spec.ts +++ b/e2e-tests/specs/admin/tpc-notice-install.spec.ts @@ -1,11 +1,36 @@ import { test, expect } from '@playwright/test'; -import { visitAdminPage } from '../../utils'; +import { setCustomizeSettings, visitAdminPage } from '../../utils'; + +const TPC_PLUGIN = + 'templates-patterns-collection/templates-patterns-collection'; +const TEST_NAME = 'tpcNotice'; test.describe('Dashboard Notice', () => { + test.beforeAll(async ({ request, baseURL }) => { + const endpoint = `${baseURL}/wp-json/wp/v2/plugins/${TPC_PLUGIN}`; + + await request + .put(endpoint, { data: { status: 'inactive' } }) + .catch(() => null); + await request.delete(endpoint).catch(() => null); + + // Overridden per-request via ?test_name=, not written to the options table. + await setCustomizeSettings( + TEST_NAME, + { + options: { + neve_notice_dismissed: 'no', + neve_install: Math.floor(Date.now() / 1000), + }, + }, + { request, baseURL } + ); + }); + test('Starter Sites Plugin install from Dashboard Notice', async ({ page, }) => { - await visitAdminPage(page, 'index.php', ''); + await visitAdminPage(page, 'index.php', `test_name=${TEST_NAME}`); await expect(page).toHaveURL(/wp-admin\/index.php/); @@ -25,9 +50,7 @@ test.describe('Dashboard Notice', () => { ); // Welcome screen - await expect(page.locator('h1')).toContainText( - 'Choose a design' - ); + await expect(page.locator('h1')).toContainText('Choose a design'); const categories = await page.locator('.ob-cat-wrap .cat'); await expect(categories).toContainText([ diff --git a/e2e-tests/specs/customizer/general/custom-global-colors.spec.ts b/e2e-tests/specs/customizer/general/custom-global-colors.spec.ts index bc0cf18b04..bccdb88f37 100644 --- a/e2e-tests/specs/customizer/general/custom-global-colors.spec.ts +++ b/e2e-tests/specs/customizer/general/custom-global-colors.spec.ts @@ -33,11 +33,37 @@ test.describe('Custom Global Color Control', () => { ); await clearWelcome(page); - await page.locator('[aria-label="Document Overview"]').click(); - await page.locator('.block-editor-list-view-leaf').first().click(); + await page.waitForFunction(() => + ( + window.wp?.data?.select('core/block-editor')?.getSettings() + ?.colors || [] + ).some((color: { slug: string }) => color.slug === 'custom-1') + ); + + // Wait for the parsed block list too, so the update below has a target. + await page.waitForFunction( + () => + ( + window.wp?.data?.select('core/block-editor')?.getBlocks() || + [] + ).length > 0 + ); - await page.getByRole('button', { name: 'Background' }).click(); - await page.getByRole('option', { name: 'Custom 1' }).click(); + // Clicking the palette swatch toggles, so a block left coloured by a + // previous run would be cleared rather than set. + await page.evaluate(() => { + const { data } = window.wp; + const [block] = data.select('core/block-editor').getBlocks(); + data.dispatch('core/block-editor').updateBlockAttributes( + block.clientId, + { backgroundColor: 'custom-1' } + ); + }); + await page.waitForFunction( + () => + window.wp?.data?.select('core/block-editor')?.getBlocks()?.[0] + ?.attributes?.backgroundColor === 'custom-1' + ); await savePost(page); }); diff --git a/e2e-tests/specs/customizer/hfg/hfg-footer-menu-component.spec.ts b/e2e-tests/specs/customizer/hfg/hfg-footer-menu-component.spec.ts index 28464f3feb..6aedc601fe 100644 --- a/e2e-tests/specs/customizer/hfg/hfg-footer-menu-component.spec.ts +++ b/e2e-tests/specs/customizer/hfg/hfg-footer-menu-component.spec.ts @@ -1,6 +1,21 @@ -import { test, expect, Page, Locator } from '@playwright/test'; -import { setCustomizeSettings } from '../../../utils'; +import { test, expect, Page, Locator, Frame } from '@playwright/test'; +import { loginWithRequest, setCustomizeSettings } from '../../../utils'; import data from '../../../fixtures/customizer/hfg/footer-menu-setup.json'; +import menuData from '../../../fixtures/customizer/hfg/footer-menu-both-devices-setup.json'; + +const PREVIEW_FRAME = 'iframe[name="customize-preview-0"]'; + +/** + * Collect the ids of every footer menu list in a document. + * + * @param {Frame} frame Frame to read from. + */ +const footerMenuIds = (frame: Frame): Promise => + frame.evaluate(() => + Array.from(document.querySelectorAll('.hfg_footer ul.footer-menu')).map( + (el) => el.id + ) + ); test.describe('Footer Menu component', function () { let page: Page; @@ -35,3 +50,137 @@ test.describe('Footer Menu component', function () { } }); }); + +test.describe( + 'Footer Menu component in both desktop and mobile layouts', + function () { + test.beforeAll(async ({ request, baseURL }) => { + await setCustomizeSettings('hfgFooterMenuBothDevices', menuData, { + request, + baseURL, + }); + }); + + test('Both layout variants are rendered', async ({ page }) => { + await page.goto('/?test_name=hfgFooterMenuBothDevices'); + + await expect( + page.locator( + '.hfg_footer .footer--row[data-show-on="desktop"] .nav-menu-footer' + ) + ).toHaveCount(1); + + await expect( + page.locator( + '.hfg_footer .footer--row[data-show-on="mobile"] .nav-menu-footer' + ) + ).toHaveCount(1); + }); + + test('Menu IDs are unique across the whole document', async ({ + page, + }) => { + await page.goto('/?test_name=hfgFooterMenuBothDevices'); + + await expect(page.locator('ul#footer-menu')).toHaveCount(0); + await expect( + page.locator('ul#footer-menu-desktop-bottom') + ).toHaveCount(1); + await expect(page.locator('ul#footer-menu-mobile-top')).toHaveCount( + 1 + ); + + const duplicated = await page.evaluate(() => { + const ids = Array.from( + document.querySelectorAll('.hfg_footer [id]') + ).map((el) => el.id); + + return ids.filter((id, index) => ids.indexOf(id) !== index); + }); + + expect(duplicated).toEqual([]); + }); + + test('The footer-menu class is kept for styling', async ({ page }) => { + await page.goto('/?test_name=hfgFooterMenuBothDevices'); + + await expect( + page.locator('.hfg_footer ul.footer-menu') + ).toHaveCount(2); + }); + + test('Menu IDs survive a selective refresh of the component', async ({ + page, + }) => { + const previewUrl = '/?test_name=hfgFooterMenuBothDevices'; + await loginWithRequest( + '/wp-admin/customize.php?url=' + encodeURIComponent(previewUrl), + page + ); + + await page.waitForSelector('.wp-full-overlay-sidebar', { + state: 'visible', + }); + + const preview = page.frameLocator(PREVIEW_FRAME); + await preview + .locator('.hfg_footer ul.footer-menu') + .first() + .waitFor(); + + const frame = page.frame({ name: 'customize-preview-0' }); + if (!frame) { + throw new Error('Customizer preview frame not found.'); + } + + // Every placement has to advertise where it sits, otherwise selective refresh + // renders the partial once and copies that markup into all of them. + const contexts = await frame.evaluate(() => + Array.from( + document.querySelectorAll('.builder-item--footer-menu') + ).map((el) => + el.getAttribute('data-customize-partial-placement-context') + ) + ); + expect(contexts).toEqual([ + '{"device":"desktop","row":"bottom"}', + '{"device":"mobile","row":"top"}', + ]); + + const before = await footerMenuIds(frame); + expect(before).toEqual([ + 'footer-menu-desktop-bottom', + 'footer-menu-mobile-top', + ]); + + // Changing a footer-menu setting refreshes the component partial. + await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).wp + .customize('footer-menu_style') + .set('style-border-bottom'); + }); + + // The style class only lands through a re-render, so this also proves the + // refresh actually ran before the ids are re-checked. + await expect( + preview + .locator( + '.footer--row[data-show-on="desktop"] .nav-menu-footer' + ) + .first() + ).toHaveClass(/style-border-bottom/); + await expect( + preview + .locator( + '.footer--row[data-show-on="mobile"] .nav-menu-footer' + ) + .first() + ).toHaveClass(/style-border-bottom/); + + const after = await footerMenuIds(frame); + expect(after).toEqual(before); + await expect(preview.locator('ul#footer-menu')).toHaveCount(0); + }); + } +); diff --git a/e2e-tests/specs/customizer/hfg/hfg-header-menu-component.spec.ts b/e2e-tests/specs/customizer/hfg/hfg-header-menu-component.spec.ts new file mode 100644 index 0000000000..afdeb1c5a4 --- /dev/null +++ b/e2e-tests/specs/customizer/hfg/hfg-header-menu-component.spec.ts @@ -0,0 +1,127 @@ +import { test, expect, Frame } from '@playwright/test'; +import { loginWithRequest, setCustomizeSettings } from '../../../utils'; +import data from '../../../fixtures/customizer/hfg/primary-menu-both-devices-setup.json'; + +const PREVIEW_FRAME = 'iframe[name="customize-preview-0"]'; + +/** + * Collect the ids of every primary menu list in a document. + * + * @param {Frame} frame Frame to read from. + */ +const primaryMenuIds = (frame: Frame): Promise => + frame.evaluate(() => + Array.from( + document.querySelectorAll('.hfg_header ul.primary-menu-ul') + ).map((el) => el.id) + ); + +test.describe( + 'Primary Menu component in both desktop and mobile layouts', + function () { + test.beforeAll(async ({ request, baseURL }) => { + await setCustomizeSettings('hfgPrimaryMenuBothDevices', data, { + request, + baseURL, + }); + }); + + test('Menu IDs are unique across the whole document', async ({ + page, + }) => { + await page.goto('/?test_name=hfgPrimaryMenuBothDevices'); + + // The row alone is not enough to tell the placements apart: both layouts + // use the same row names. + await expect( + page.locator('ul#nv-primary-navigation-main') + ).toHaveCount(0); + + await expect( + page.locator('ul#nv-primary-navigation-desktop-main') + ).toHaveCount(1); + await expect( + page.locator('ul#nv-primary-navigation-mobile-main') + ).toHaveCount(1); + await expect( + page.locator('ul#nv-primary-navigation-mobile-sidebar') + ).toHaveCount(1); + + const duplicated = await page.evaluate(() => { + const ids = Array.from( + document.querySelectorAll('.hfg_header [id]') + ).map((el) => el.id); + + return ids.filter((id, index) => ids.indexOf(id) !== index); + }); + + expect(duplicated).toEqual([]); + }); + + test('Menu IDs survive a selective refresh of the component', async ({ + page, + }) => { + const previewUrl = '/?test_name=hfgPrimaryMenuBothDevices'; + await loginWithRequest( + '/wp-admin/customize.php?url=' + encodeURIComponent(previewUrl), + page + ); + + await page.waitForSelector('.wp-full-overlay-sidebar', { + state: 'visible', + }); + + const preview = page.frameLocator(PREVIEW_FRAME); + await preview + .locator('.hfg_header ul.primary-menu-ul') + .first() + .waitFor(); + + const frame = page.frame({ name: 'customize-preview-0' }); + if (!frame) { + throw new Error('Customizer preview frame not found.'); + } + + const before = await primaryMenuIds(frame); + expect(before).toEqual([ + 'nv-primary-navigation-desktop-main', + 'nv-primary-navigation-mobile-main', + 'nv-primary-navigation-mobile-sidebar', + ]); + + // Every placement has to advertise where it sits, otherwise selective refresh + // renders the partial once and copies that markup into all of them. + const contexts = await frame.evaluate(() => + Array.from( + document.querySelectorAll('.builder-item--primary-menu') + ).map((el) => + el.getAttribute('data-customize-partial-placement-context') + ) + ); + expect(contexts).toEqual([ + '{"device":"desktop","row":"main"}', + '{"device":"mobile","row":"main"}', + '{"device":"mobile","row":"sidebar"}', + ]); + + // Refresh the partial and check that all placements were refreshed and that the menu ids are still unique. + const refreshed = await frame.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sr = (window as any).wp.customize.selectiveRefresh; + const partial = sr.partial('primary-menu_partial'); + if (!partial) { + throw new Error('primary-menu_partial is not registered.'); + } + const placements = await partial.refresh(); + + return placements.length; + }); + expect(refreshed).toBe(3); + + expect(await primaryMenuIds(frame)).toEqual(before); + await expect( + preview.locator('ul#nv-primary-navigation-main') + ).toHaveCount(0); + }); + } +); diff --git a/e2e-tests/specs/customizer/hfg/hfg-logo-component.spec.ts b/e2e-tests/specs/customizer/hfg/hfg-logo-component.spec.ts index ff8b6d5d29..a91e8cad2b 100644 --- a/e2e-tests/specs/customizer/hfg/hfg-logo-component.spec.ts +++ b/e2e-tests/specs/customizer/hfg/hfg-logo-component.spec.ts @@ -1,9 +1,5 @@ import { test, expect, APIRequestContext, Page } from '@playwright/test'; -import { - setCustomizeSettings, - testForViewport, - visitAdminPage, -} from '../../../utils'; +import { setCustomizeSettings, testForViewport } from '../../../utils'; import data from '../../../fixtures/customizer/hfg/hfg-logo-component.json'; interface TestOptions { @@ -87,23 +83,24 @@ test.describe('Logo Component palette', function () { test.beforeAll(async ({ browser, request, baseURL }) => { page = await browser.newPage(); - await visitAdminPage(page, 'upload.php', ''); - await page.waitForSelector('.attachment'); - const imageLocators = await page.locator('.attachment').count(); - - for (let i = 0; i < Math.min(imageLocators, 2); i++) { - const imageLocator = await page.locator( - `.attachment:nth-child(${i + 1})` - ); - await imageLocator.click(); - const urlString = page.url(); - const url = new URL(urlString); - const imageId = url.searchParams.get('item') || ''; - const imageUrl = await page - .locator('#attachment-details-two-column-copy-link') - .getAttribute('value'); - logos.push({ id: imageId, url: imageUrl }); - await page.goBack(); // Go back to the previous page to select the next image + + // Queried instead of walked through the media grid: the newest sample-data + // attachment is a video, and grid order differs between environments. + // orderby=id keeps the pick stable for a given database. + const response = await request.get( + baseURL + + '/wp-json/wp/v2/media?media_type=image&per_page=2&orderby=id&order=asc' + ); + expect(response.ok()).toBeTruthy(); + + const attachments = await response.json(); + expect(attachments.length).toBeGreaterThan(0); + + for (const attachment of attachments.slice(0, 2)) { + logos.push({ + id: String(attachment.id), + url: attachment.source_url, + }); } const { palette } = data; diff --git a/e2e-tests/specs/customizer/hfg/hfg-menu-item-alignment.spec.ts b/e2e-tests/specs/customizer/hfg/hfg-menu-item-alignment.spec.ts index d05b9ac90d..0cc59c7b23 100644 --- a/e2e-tests/specs/customizer/hfg/hfg-menu-item-alignment.spec.ts +++ b/e2e-tests/specs/customizer/hfg/hfg-menu-item-alignment.spec.ts @@ -17,18 +17,23 @@ test.describe('Menu item alignment', function () { await page.locator('.mobile-left .navbar-toggle').click(); await expect( page.locator( - '#nv-primary-navigation-sidebar .menu-item-title-wrap:has-text("About The Tests")' + '#nv-primary-navigation-mobile-sidebar .menu-item-title-wrap:has-text("About The Tests")' ) ).toHaveCSS('text-align', 'left'); + // Located by label: menu item IDs are DB auto-increments and differ + // between a fresh CI import and a long-lived local database. await page .locator( - '#nv-primary-navigation-sidebar > .menu-item-1643 > .wrap > .caret-wrap' + '#nv-primary-navigation-mobile-sidebar > li.menu-item-has-children' ) + .filter({ hasText: 'Level 1' }) + .locator('.caret-wrap') + .first() .click(); await expect( page.locator( - '#nv-primary-navigation-sidebar .menu-item-title-wrap:has-text("Level 2")' + '#nv-primary-navigation-mobile-sidebar .menu-item-title-wrap:has-text("Level 2")' ) ).toHaveCSS('text-align', 'left'); }); diff --git a/e2e-tests/specs/customizer/hfg/hfg-menu-item-wrap.spec.ts b/e2e-tests/specs/customizer/hfg/hfg-menu-item-wrap.spec.ts index 7132e6a303..bc614638b9 100644 --- a/e2e-tests/specs/customizer/hfg/hfg-menu-item-wrap.spec.ts +++ b/e2e-tests/specs/customizer/hfg/hfg-menu-item-wrap.spec.ts @@ -11,7 +11,7 @@ test.describe('Menu item alignment', function () { .click(); const firstLevelItem = page - .locator('#nv-primary-navigation-sidebar') + .locator('#nv-primary-navigation-mobile-sidebar') .getByRole('link', { name: 'Page Markup And Formatting', }); @@ -23,7 +23,7 @@ test.describe('Menu item alignment', function () { await page.getByRole('button', { name: 'Toggle Level 2' }).click(); const secondLevelItem = page - .locator('#nv-primary-navigation-sidebar') + .locator('#nv-primary-navigation-mobile-sidebar') .getByRole('link', { name: 'Level 3b', }); diff --git a/e2e-tests/specs/customizer/hfg/hfg-submenu-viewport.spec.ts b/e2e-tests/specs/customizer/hfg/hfg-submenu-viewport.spec.ts new file mode 100644 index 0000000000..dd6f06eff6 --- /dev/null +++ b/e2e-tests/specs/customizer/hfg/hfg-submenu-viewport.spec.ts @@ -0,0 +1,229 @@ +import { test, expect, APIRequestContext, Page } from '@playwright/test'; +import { setCustomizeSettings } from '../../../utils'; +import data from '../../../fixtures/customizer/hfg/submenu-viewport-setup.json'; + +const VIEWPORT = { width: 1280, height: 900 }; +const TEST_NAME = 'hfgSubmenuViewport'; +const MENU_NAME = 'Submenu Viewport Test'; + +// Only the last item has a submenu, so the dropdown opens at the right edge of +// the viewport. +const ITEMS = ['Home', 'Blog', 'About', 'Team', 'Services']; +const CHILDREN = ['Consulting', 'Support']; +const GRANDCHILD = 'Strategy'; + +/** + * Neve Pro publishes the submenu alignment control as `right: var(--alignment)`, + * with `auto` for the Left option. Replicated here, since the control lives in + * the Pro plugin. + */ +const LEFT_ALIGNED_DROPDOWNS = '.nav-ul > li > .sub-menu { right: auto; }'; +const LAST_ITEM = '.header--row .nv-nav-wrap .nav-ul > li:last-child'; + +type Menu = { id: number; locations: string[] }; + +const restURL = (baseURL: string | undefined, path: string) => + `${baseURL ?? ''}/wp-json/wp/v2/${path}`; + +const post = async ( + request: APIRequestContext, + baseURL: string | undefined, + path: string, + body: Record +) => { + const response = await request.post(restURL(baseURL, path), { data: body }); + expect(response.ok()).toBeTruthy(); + return await response.json(); +}; + +// The inline styles the reposition logic writes on a submenu. +const getInlineStyles = async (page: Page, selector: string) => { + return await page.evaluate((subMenu: string) => { + const element = document.querySelector(subMenu); + if (!(element instanceof HTMLElement)) { + throw new Error(`Missing element: ${subMenu}`); + } + const { right, left, transform } = element.style; + return { right, left, transform }; + }, selector); +}; + +const reposition = async (page: Page) => { + await page.setViewportSize({ + width: VIEWPORT.width - 1, + height: VIEWPORT.height, + }); + await page.setViewportSize(VIEWPORT); +}; + +const getBox = async (page: Page, selector: string) => { + return await page.evaluate((element: string) => { + const node = document.querySelector(element); + if (!node) { + throw new Error(`Missing element: ${element}`); + } + const { left, right, top, bottom, width, height } = + node.getBoundingClientRect(); + return { left, right, top, bottom, width, height }; + }, selector); +}; + +test.describe('Submenu viewport repositioning', function () { + test.describe.configure({ mode: 'serial' }); + test.use({ viewport: VIEWPORT }); + + let menuId = 0; + let previousMenuId: number | undefined; + + test.beforeAll(async ({ request, baseURL }) => { + const menus: Menu[] = await request + .get(restURL(baseURL, 'menus?per_page=100')) + .then((response) => response.json()); + previousMenuId = menus.find((menu) => + menu.locations.includes('primary') + )?.id; + + const menu = await post(request, baseURL, 'menus', { + name: MENU_NAME, + locations: ['primary'], + }); + menuId = menu.id; + + let lastItemId = 0; + for (const title of ITEMS) { + const item = await post(request, baseURL, 'menu-items', { + title, + url: '#', + menus: menuId, + status: 'publish', + }); + lastItemId = item.id; + } + + let firstChildId = 0; + for (const title of CHILDREN) { + const child = await post(request, baseURL, 'menu-items', { + title, + url: '#', + menus: menuId, + status: 'publish', + parent: lastItemId, + }); + firstChildId = firstChildId || child.id; + } + + await post(request, baseURL, 'menu-items', { + title: GRANDCHILD, + url: '#', + menus: menuId, + status: 'publish', + parent: firstChildId, + }); + + // After the menu is in place: the settings endpoint snapshots the menu + // locations, and serves that snapshot back for `test_name` requests. + await setCustomizeSettings(TEST_NAME, data, { request, baseURL }); + }); + + test.afterAll(async ({ request, baseURL }) => { + await request.delete(restURL(baseURL, `menus/${menuId}?force=true`)); + if (previousMenuId) { + await post(request, baseURL, `menus/${previousMenuId}`, { + locations: ['primary'], + }); + } + }); + + test.beforeEach(async ({ page }) => { + await page.goto('/?test_name=' + TEST_NAME); + + const parent = page.locator(LAST_ITEM); + await expect(parent).toHaveClass(/menu-item-has-children/); + await expect(parent).toContainText(ITEMS[ITEMS.length - 1]); + + // With the container at its maximum width and the menu on the right, + // the last item sits at the right edge of the viewport. + const parentBox = await getBox(page, LAST_ITEM); + expect(VIEWPORT.width - parentBox.right).toBeLessThan(60); + + await page.addStyleTag({ content: LEFT_ALIGNED_DROPDOWNS }); + // Left aligned, the dropdown does not fit next to its parent item. + const dropdownBox = await getBox(page, LAST_ITEM + ' > .sub-menu'); + expect(parentBox.left + dropdownBox.width).toBeGreaterThan( + VIEWPORT.width + ); + + await reposition(page); + await page.waitForFunction((selector) => { + const dropdown = document.querySelector( + selector + ) as HTMLElement | null; + return dropdown !== null && dropdown.style.right !== ''; + }, LAST_ITEM + ' > .sub-menu'); + }); + + test('Keeps the hovered dropdown under its parent item', async ({ + page, + }) => { + const parentItem = page.locator(LAST_ITEM); + const dropdown = page.locator(LAST_ITEM + ' > .sub-menu'); + + await parentItem.hover(); + await expect(dropdown).toBeVisible(); + + const parentBox = await getBox(page, LAST_ITEM); + const dropdownBox = await getBox(page, LAST_ITEM + ' > .sub-menu'); + + // The pointer rests in the middle of the parent item, and the dropdown + // starts right below it - no gap to cross on the way down to it. + const pointer = parentBox.left + parentBox.width / 2; + expect(pointer).toBeGreaterThan(dropdownBox.left); + expect(pointer).toBeLessThan(dropdownBox.right); + expect(Math.abs(dropdownBox.top - parentBox.bottom)).toBeLessThan(2); + expect(dropdownBox.left).toBeGreaterThanOrEqual(0); + expect(dropdownBox.right).toBeLessThanOrEqual(VIEWPORT.width); + + // Aligned to the parent item instead of moved next to it. + expect(await getInlineStyles(page, LAST_ITEM + ' > .sub-menu')).toEqual( + { + right: '0px', + left: 'auto', + transform: '', + } + ); + }); + + test('Flips the hovered flyout to the side of its parent item', async ({ + page, + }) => { + const flyoutParentSelector = + LAST_ITEM + ' > .sub-menu > li:first-child'; + const flyoutSelector = flyoutParentSelector + ' > .sub-menu'; + const flyoutParentItem = page.locator(flyoutParentSelector); + const flyout = page.locator(flyoutSelector); + + await page.locator(LAST_ITEM).hover(); + await flyoutParentItem.hover(); + await expect(flyout).toBeVisible(); + + const flyoutParentBox = await getBox(page, flyoutParentSelector); + const flyoutBox = await getBox(page, flyoutSelector); + + // The pointer rests in the middle of the flyout parent item, and the + // flyout touches its left edge at the same height - no gap sideways. + const pointer = flyoutParentBox.top + flyoutParentBox.height / 2; + expect(pointer).toBeGreaterThan(flyoutBox.top); + expect(pointer).toBeLessThan(flyoutBox.bottom); + expect(Math.abs(flyoutParentBox.left - flyoutBox.right)).toBeLessThan( + 2 + ); + expect(flyoutBox.left).toBeGreaterThanOrEqual(0); + expect(flyoutBox.right).toBeLessThanOrEqual(VIEWPORT.width); + + expect(await getInlineStyles(page, flyoutSelector)).toEqual({ + right: '100%', + left: 'auto', + transform: '', + }); + }); +}); diff --git a/e2e-tests/specs/customizer/layout/blog-archive-settings.spec.ts b/e2e-tests/specs/customizer/layout/blog-archive-settings.spec.ts index a8fb3386a1..1cbcf2c52e 100755 --- a/e2e-tests/specs/customizer/layout/blog-archive-settings.spec.ts +++ b/e2e-tests/specs/customizer/layout/blog-archive-settings.spec.ts @@ -2,6 +2,22 @@ import { test, expect } from '@playwright/test'; import { setCustomizeSettings } from '../../../utils'; import data from '../../../fixtures/customizer/layout/blog-archive-setting-setup.json'; +const SEARCH_TERM = 'nvexcerpthtmlfixture'; +const POST_SLUG = 'nv-excerpt-html-fixture'; +const LINK_HREF = 'https://example.com/neve-excerpt-link'; +const EXCERPT = `Neve keeps this excerpt link visible while trimming the rest sentinelword away.`; + +const excerptHtmlSettings = { + neve_blog_archive_layout: 'default', + neve_post_excerpt_length: 8, + neve_post_content_ordering: '["title-meta","excerpt"]', +}; + +const excerptHtmlCutSettings = { + ...excerptHtmlSettings, + neve_post_excerpt_length: 4, +}; + test.describe('Blog/Archive 1 / Default Layout', () => { test.beforeAll(async ({ request, baseURL }) => { await setCustomizeSettings('defaultLayout', data.archive1, { @@ -265,14 +281,16 @@ test.describe('Blog/Archive 3 / Covers Layout', () => { test.describe('Blog/Archive 4 / Default Layout', () => { test.beforeAll(async ({ request, baseURL }) => { - await setCustomizeSettings('defaultLayout', data.archive4, { + // Distinct scoped name: sharing 'defaultLayout' with Archive 1 meant + // whichever beforeAll ran last decided both describes' settings. + await setCustomizeSettings('defaultLayoutNoThumbnail', data.archive4, { request, baseURL, }); }); test('Tests If Post Thumbnail Class Is Removed', async ({ page }) => { - await page.goto('/?test_name=defaultLayout'); + await page.goto('/?test_name=defaultLayoutNoThumbnail'); await page.waitForSelector('article.post'); @@ -281,3 +299,97 @@ test.describe('Blog/Archive 4 / Default Layout', () => { ).toEqual(0); }); }); + +test.describe('Blog/Archive / Excerpt markup', () => { + test.describe.configure({ mode: 'serial' }); + + let postId: number; + + test.beforeAll(async ({ request, baseURL }) => { + await setCustomizeSettings('excerptHtml', excerptHtmlSettings, { + request, + baseURL, + }); + await setCustomizeSettings('excerptHtmlCut', excerptHtmlCutSettings, { + request, + baseURL, + }); + + // A run killed before afterAll leaves the post behind; drop it by slug so + // the search page has exactly one result either way. + const stale = await request.get( + baseURL + `/wp-json/wp/v2/posts?slug=${POST_SLUG}&status=any` + ); + expect(stale.ok()).toBeTruthy(); + for (const post of await stale.json()) { + const staleDeleteResponse = await request.delete( + baseURL + `/wp-json/wp/v2/posts/${post.id}?force=true` + ); + expect(staleDeleteResponse.ok()).toBeTruthy(); + } + + const response = await request.post(baseURL + '/wp-json/wp/v2/posts', { + data: { + title: `Excerpt markup ${SEARCH_TERM}`, + slug: POST_SLUG, + content: `Body copy for ${SEARCH_TERM}.`, + excerpt: EXCERPT, + status: 'publish', + // Dated far back so the post lands on the last archive page and + // leaves the other archive specs alone. + date: '2001-01-01T00:00:00', + }, + }); + expect(response.ok()).toBeTruthy(); + postId = (await response.json()).id; + }); + + test.afterAll(async ({ request, baseURL }) => { + if (postId) { + const deleteResponse = await request.delete( + baseURL + `/wp-json/wp/v2/posts/${postId}?force=true` + ); + expect(deleteResponse.ok()).toBeTruthy(); + } + }); + + test('Trimming the excerpt keeps its HTML', async ({ page }) => { + await page.goto(`/?s=${SEARCH_TERM}&test_name=excerptHtml`); + + const excerpt = page.locator('article.post .excerpt-wrap'); + await expect(excerpt).toHaveCount(1); + + const link = excerpt.locator(`a[href="${LINK_HREF}"]`); + await expect(link).toBeVisible(); + await expect(link).toHaveText('excerpt link'); + await expect(excerpt.locator('strong')).toHaveText('keeps'); + + const text = await excerpt.innerText(); + // The trim still happens: the last kept word is in, the next one is out. + expect(text).toContain('trimming'); + expect(text).not.toContain('sentinelword'); + // Markup is rendered, not printed as escaped text. + expect(text).not.toContain(' { + await page.goto(`/?s=${SEARCH_TERM}&test_name=excerptHtmlCut`); + + const excerpt = page.locator('article.post .excerpt-wrap'); + await expect(excerpt).toHaveCount(1); + + // Word 4 is the first half of the link text and word 5 is past the cut, so + // only an anchor closed by the trim can render as a link at all. + const link = excerpt.locator(`a[href="${LINK_HREF}"]`); + await expect(link).toBeVisible(); + await expect(link).toHaveText('excerpt'); + // The read more marker follows the link instead of being swallowed by it. + await expect(link).not.toContainText('…'); + await expect(excerpt.locator('strong')).toHaveText('keeps'); + + const text = await excerpt.innerText(); + expect(text).toContain('…'); + expect(text).not.toContain('visible'); + expect(text).not.toContain(' { // Wait for customizer to fully load await page.waitForSelector('.wp-full-overlay-sidebar', { state: 'visible' }); - // Wait a bit more for all scripts to initialize - await page.waitForTimeout(1000); + // The controls bundle injects this button at runtime, Booting the + // customizer plus its React bundle is the slowest step in the suite and can + // pass the default action timeout on a loaded machine, hence the override. + const styleBookButton = page.locator('#neve-style-book'); + await styleBookButton.waitFor({ state: 'visible', timeout: 60_000 }); // Open Style Book for all tests - await page.getByRole('button', { name: ' Style Book' }).click(); + await styleBookButton.click(); // Wait for Style Book to appear in the iframe await page diff --git a/e2e-tests/specs/customizer/typography/font-family.spec.ts b/e2e-tests/specs/customizer/typography/font-family.spec.ts index 32899fb172..d80a6c15fb 100755 --- a/e2e-tests/specs/customizer/typography/font-family.spec.ts +++ b/e2e-tests/specs/customizer/typography/font-family.spec.ts @@ -21,15 +21,18 @@ test.describe('Font Family', () => { '/markup-html-tags-and-formatting/?test_name=fontFamily' ); - await expect(await page.locator('body')).toHaveCSS( + await expect(page.locator('body')).toHaveCSS( 'font-family', new RegExp(`${fonts.general}`) ); for (const heading of headingSelectors) { - const headings = await page.locator(heading); - for (let index = 0; index < (await headings.count()); index++) { - await expect(await headings.nth(index)).toHaveCSS( + const headings = page.locator(heading); + await expect(headings).not.toHaveCount(0); + + const count = await headings.count(); + for (let index = 0; index < count; index++) { + await expect(headings.nth(index)).toHaveCSS( 'font-family', new RegExp(`${fonts.headings}`) ); diff --git a/e2e-tests/specs/editor/page.spec.ts b/e2e-tests/specs/editor/page.spec.ts index 1535de4b69..4cbf9ed28f 100644 --- a/e2e-tests/specs/editor/page.spec.ts +++ b/e2e-tests/specs/editor/page.spec.ts @@ -1,17 +1,23 @@ import { test, expect } from '@playwright/test'; +import { clearWelcome } from '../../utils'; test.describe('Page Neve Options / Title Visibility', () => { - test('Starter Sites Plugin install from Dashboard Notice', async ({ - page, - }) => { + test('Disable Title dims the title in the editor', async ({ page }) => { await page.goto('wp-admin/post-new.php?post_type=page'); - await page.getByRole('textbox', { name: 'Add title' }).click(); - await page - .getByRole('textbox', { name: 'Add title' }) - .fill('Test Title Visibility'); + await clearWelcome(page); + const welcomeGuide = page.locator('.edit-post-welcome-guide'); + if (await welcomeGuide.isVisible().catch(() => false)) { + await welcomeGuide.getByRole('button', { name: 'Close' }).click(); + } + await expect(welcomeGuide).not.toBeVisible(); + + const canvas = page.frameLocator('iframe[name="editor-canvas"]'); + const titleLocator = canvas.locator('h1.editor-post-title'); + + await titleLocator.click(); + await titleLocator.fill('Test Title Visibility'); await page.getByRole('button', { name: 'Neve Options' }).click(); - const titleLocator = page.locator('h1.editor-post-title'); await expect(titleLocator).toHaveCSS('opacity', '1'); await page.getByLabel('Disable Title').check(); await expect(titleLocator).toHaveCSS('opacity', '0.5'); diff --git a/e2e-tests/utils.ts b/e2e-tests/utils.ts index 3ca0cf3298..d3ef35bd60 100644 --- a/e2e-tests/utils.ts +++ b/e2e-tests/utils.ts @@ -155,16 +155,28 @@ export async function getPageError(page: Page) { * @param {Page} page - A playwright Page object representing the web page */ export const clearWelcome = async (page: Page) => { + // The editor registers its stores asynchronously, so selecting one straight + // after navigation returns undefined and the guard below would throw. + await page + .waitForFunction( + () => !!window.wp?.data?.select('core/edit-post'), + null, + { + timeout: 15_000, + } + ) + .catch(() => { + // Nothing to clear if the editor never came up; let the test report that. + }); + await page.evaluate(() => { - // eslint-disable-next-line no-unused-expressions - window.wp && - window.wp.data && - window.wp.data - .select('core/edit-post') - .isFeatureActive('welcomeGuide') && + const editPost = window.wp?.data?.select('core/edit-post'); + + if (editPost?.isFeatureActive?.('welcomeGuide')) { window.wp.data .dispatch('core/edit-post') .toggleFeature('welcomeGuide'); + } }); }; @@ -267,12 +279,13 @@ export const testForViewport = async ( } ) => { await page.setViewportSize(viewPort); - const elements = await page.locator(selector); - const count = await elements.count(); - await expect(count).toBeGreaterThan(0); + const elements = page.locator(selector); + // Retrying assertion: lets the layout settle after the resize. + await expect(elements).not.toHaveCount(0); - for (let index = 0; index < (await elements.count()); index++) { - const element = await elements.nth(index); + const count = await elements.count(); + for (let index = 0; index < count; index++) { + const element = elements.nth(index); for (const cssProperty of viewportData.cssProperties) { await expect(element).toHaveCSS( @@ -288,8 +301,13 @@ export const checkElementsOrder = async ( containerSelector: string, expectedOrder: string[] ) => { - const elements = await page.locator(containerSelector + ' > *'); - for (let i = 0; i < (await elements.count()); i++) { + const elements = page.locator(containerSelector + ' > *'); + // Exact count, so extra or missing children are reported as such rather than + // as a confusing class mismatch part-way through the loop — or, when the + // container is empty, not reported at all. + await expect(elements).toHaveCount(expectedOrder.length); + + for (let i = 0; i < expectedOrder.length; i++) { await expect(elements.nth(i)).toHaveClass( new RegExp(`${expectedOrder[i]}`) ); diff --git a/globals/migrations.php b/globals/migrations.php index c74c608a68..e7f4b8a8bb 100644 --- a/globals/migrations.php +++ b/globals/migrations.php @@ -127,6 +127,9 @@ function neve_migrate_blog_columns() { * @return void */ function neve_run_migration_flags() { + if ( ! class_exists( Migration_Flags::class ) ) { + return; + } $migrator = new Migration_Flags( NEVE_VERSION ); $migrator->run(); } diff --git a/globals/sanitize-functions.php b/globals/sanitize-functions.php index 47fe9d1489..c2143336b0 100644 --- a/globals/sanitize-functions.php +++ b/globals/sanitize-functions.php @@ -10,17 +10,48 @@ */ /** - * Function to sanitize alpha color. + * Check whether a value is a well formed CSS variable expression. * - * @param string $value Hex or RGBA color. + * @param mixed $value Value to check. + * + * @return bool + */ +function neve_is_css_var( $value ) { + if ( ! is_string( $value ) ) { + return false; + } + + $value = trim( $value ); + + if ( $value === '' || strlen( $value ) > 200 ) { + return false; + } + + $hex = '#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})'; + $color_fn = '(?:rgb|rgba|hsl|hsla)\(\s*[0-9a-z.,%\/\s-]+\)'; + $keyword = '[a-z]+(?:-[a-z]+)*'; + $fallback = '(?:(?P>nv_var)|' . $hex . '|' . $color_fn . '|' . $keyword . ')'; + $pattern = '/^(?Pvar\(\s*--[a-z0-9_-]+\s*(?:,\s*' . $fallback . '\s*)?\))$/i'; + + return (bool) preg_match( $pattern, $value ); +} + +/** + * Sanitize a color control value. + * + * @param mixed $value Color value: CSS variable, gradient, rgba() or hex. * * @return string */ function neve_sanitize_colors( $value ) { - $is_var = ( strpos( $value, 'var' ) !== false ); + if ( ! is_string( $value ) && ! is_numeric( $value ) ) { + return ''; + } + + $value = (string) $value; - if ( $is_var ) { - return sanitize_text_field( $value ); + if ( neve_is_css_var( $value ) ) { + return trim( $value ); } if ( false !== strpos( $value, 'gradient' ) ) { @@ -31,19 +62,25 @@ function neve_sanitize_colors( $value ) { $mode = ( false === strpos( $value, 'rgba' ) ) ? 'hex' : 'rgba'; if ( 'rgba' === $mode ) { return neve_sanitize_rgba( $value ); - } else { - return sanitize_hex_color( $value ); } + + $hex_color = sanitize_hex_color( $value ); + + return null === $hex_color ? '' : $hex_color; } /** * Sanitize rgba color. * - * @param string $value Color in rgba format. + * @param mixed $value Color in rgba format. * * @return string */ function neve_sanitize_rgba( $value ) { + if ( ! is_string( $value ) ) { + return 'rgba(0,0,0,0)'; + } + $red = 'rgba(0,0,0,0)'; $green = 'rgba(0,0,0,0)'; $blue = 'rgba(0,0,0,0)'; @@ -165,18 +202,17 @@ function neve_sanitize_background( $value ) { } - $value['imageUrl'] = esc_url( $value['imageUrl'] ); - $value['colorValue'] = neve_sanitize_colors( $value['colorValue'] ); - $value['overlayColorValue'] = neve_sanitize_colors( $value['overlayColorValue'] ); - + $value['imageUrl'] = esc_url( $value['imageUrl'] ?? '' ); + $value['colorValue'] = neve_sanitize_colors( $value['colorValue'] ?? '' ); + $value['overlayColorValue'] = neve_sanitize_colors( $value['overlayColorValue'] ?? '' ); - $value['overlayOpacity'] = (int) $value['overlayOpacity']; + $value['overlayOpacity'] = (int) ( $value['overlayOpacity'] ?? 0 ); if ( $value['overlayOpacity'] > 100 || $value['overlayOpacity'] < 0 ) { $value['overlayOpacity'] = 50; } - $value['fixed'] = (bool) $value['fixed']; - $value['useFeatured'] = (bool) $value['useFeatured']; + $value['fixed'] = (bool) ( $value['fixed'] ?? false ); + $value['useFeatured'] = (bool) ( $value['useFeatured'] ?? false ); return $value; } diff --git a/header-footer-grid/Core/Builder/Abstract_Builder.php b/header-footer-grid/Core/Builder/Abstract_Builder.php index 7c8c0e4936..90b0a4c8b2 100644 --- a/header-footer-grid/Core/Builder/Abstract_Builder.php +++ b/header-footer-grid/Core/Builder/Abstract_Builder.php @@ -826,7 +826,7 @@ public function get_current_slot_index() { public function render() { $layout = $this->get_layout_data(); self::$current_builder = $this->get_id(); - if ( is_customize_preview() ) { + if ( is_customize_preview() && class_exists( Css_Generator::class ) ) { $style = $this->add_style( [] ); $generator = new Css_Generator(); $generator->set( $style ); @@ -1710,6 +1710,10 @@ public function get_component( $id = null ) { $id = ( self::$current_component === null ) ? Abstract_Component::$current_component : self::$current_component; } + if ( $id === null || ! isset( $this->builder_components[ $id ] ) ) { + return null; + } + return $this->builder_components[ $id ]; } diff --git a/header-footer-grid/Core/Components/Abstract_Component.php b/header-footer-grid/Core/Components/Abstract_Component.php index a4287b0819..3e88c8d310 100644 --- a/header-footer-grid/Core/Components/Abstract_Component.php +++ b/header-footer-grid/Core/Components/Abstract_Component.php @@ -22,6 +22,7 @@ use Neve\Core\Styles\Dynamic_Selector; use Neve\Views\Font_Manager; use WP_Customize_Manager; +use WP_Customize_Partial; /** * Class Abstract_Component @@ -617,16 +618,37 @@ public function customize_register( WP_Customize_Manager $wp_customize ) { /** * Render component markup. * - * @param string $device Current device. + * @param string|WP_Customize_Partial $device Current device. + * @param array $container_context Placement context, when rendering a partial. */ - public function render( $device = '' ) { + public function render( $device = '', $container_context = array() ) { + $is_partial_render = $device instanceof WP_Customize_Partial; + $previous_device = Abstract_Builder::$current_device; + $previous_row = Abstract_Builder::$current_row; + + if ( $is_partial_render ) { + $device = isset( $container_context['device'] ) ? (string) $container_context['device'] : ''; + + if ( $device !== '' ) { + Abstract_Builder::$current_device = $device; + } + if ( isset( $container_context['row'] ) && $container_context['row'] !== '' ) { + Abstract_Builder::$current_row = (string) $container_context['row']; + } + } + $args = []; - if ( ! empty( $device ) && in_array( $device, [ 'desktop', 'tablet', 'mobile' ] ) ) { + if ( ! empty( $device ) && in_array( $device, [ 'desktop', 'tablet', 'mobile' ], true ) ) { $args['device'] = $device; } self::$current_component = $this->get_id(); Abstract_Builder::$current_builder = $this->get_builder_id(); Main::get_instance()->load( 'component-wrapper', '', $args ); + + if ( $is_partial_render ) { + Abstract_Builder::$current_device = $previous_device; + Abstract_Builder::$current_row = $previous_row; + } } /** diff --git a/header-footer-grid/Core/Components/CartIcon.php b/header-footer-grid/Core/Components/CartIcon.php index eed9f9c95a..1537f2fa81 100644 --- a/header-footer-grid/Core/Components/CartIcon.php +++ b/header-footer-grid/Core/Components/CartIcon.php @@ -108,9 +108,11 @@ public function toggle_cart_is_empty() { return ' (function($){ $(\'body\').on( \'added_to_cart\', function(){ - var responsiveCart = document.querySelector( \'.responsive-nav-cart\' ); - if ( responsiveCart ) { - responsiveCart.classList.remove(\'cart-is-empty\'); + var responsiveCart = document.querySelectorAll( \'.responsive-nav-cart\' ); + if ( responsiveCart.length ) { + responsiveCart.forEach(function(cart) { + cart.classList.remove(\'cart-is-empty\'); + }); } }); })(jQuery); diff --git a/header-footer-grid/Core/Components/Nav.php b/header-footer-grid/Core/Components/Nav.php index 9e1a1e4958..924a1fbf24 100644 --- a/header-footer-grid/Core/Components/Nav.php +++ b/header-footer-grid/Core/Components/Nav.php @@ -11,6 +11,7 @@ namespace HFG\Core\Components; +use HFG\Core\Builder\Header; use HFG\Core\Settings\Manager as SettingsManager; use HFG\Main; use Neve\Core\Settings\Mods; @@ -35,6 +36,27 @@ class Nav extends Abstract_Component { const EXPAND_DROPDOWNS = 'expand_dropdowns'; const DROPDOWNS_EXPANDED_CLASS = 'dropdowns-expanded'; + /** + * Build the menu id for the placement currently being rendered. + * + * @return string + */ + public static function get_menu_id() { + $menu_id = self::NAV_MENU_ID; + + $parts = [ + \HFG\current_device( Header::BUILDER_NAME ), + \HFG\current_row( Header::BUILDER_NAME ), + ]; + foreach ( $parts as $part ) { + if ( ! empty( $part ) ) { + $menu_id .= '-' . $part; + } + } + + return $menu_id; + } + /** * Nav constructor. * diff --git a/header-footer-grid/Core/Components/NavFooter.php b/header-footer-grid/Core/Components/NavFooter.php index 927136b7c6..839eea3488 100644 --- a/header-footer-grid/Core/Components/NavFooter.php +++ b/header-footer-grid/Core/Components/NavFooter.php @@ -124,7 +124,7 @@ public function add_settings() { 'fallback' => 'inherit', ], [ - 'selector' => '.builder-item--' . $this->get_id() . ' .nav-menu-footer:not(.style-full-height) #footer-menu li:hover > a', + 'selector' => '.builder-item--' . $this->get_id() . ' .nav-menu-footer:not(.style-full-height) .footer-menu li:hover > a', 'prop' => 'color', 'fallback' => 'inherit', ], diff --git a/header-footer-grid/functions-template.php b/header-footer-grid/functions-template.php index 9b7cac48fd..43fddc19f2 100644 --- a/header-footer-grid/functions-template.php +++ b/header-footer-grid/functions-template.php @@ -48,10 +48,10 @@ function render_components( $builder_name = '', $device = null ) { /** * Returns the current component. * - * @param string $builder_name The builder id. - * @param null $component_id The component id. + * @param string $builder_name The builder id. + * @param string|null $component_id The component id. * - * @return false|Core\Components\Abstract_Component + * @return false|null|Core\Components\Abstract_Component */ function current_component( $builder_name = '', $component_id = null ) { $builder = get_builder( $builder_name ); diff --git a/header-footer-grid/templates/component-wrapper.php b/header-footer-grid/templates/component-wrapper.php index dba1171754..96f966779e 100644 --- a/header-footer-grid/templates/component-wrapper.php +++ b/header-footer-grid/templates/component-wrapper.php @@ -10,9 +10,16 @@ namespace HFG; -$_id = current_component()->get_id(); +$_component = current_component(); + +// Bail when the current component cannot be resolved on the current builder. +if ( ! $_component instanceof Core\Components\Abstract_Component ) { + return; +} + +$_id = $_component->get_id(); if ( isset( $args ) && ! empty( $args ) ) { - current_component()->set_args( $args ); + $_component->set_args( $args ); } $item_classes = array(); @@ -28,13 +35,27 @@ $item_classes = join( ' ', $item_classes ); +// The placement context is used to restore the device and row +// when rendering a component in a selective refresh partial. +$placement_context = array(); +if ( is_customize_preview() ) { + $placement_device = current_device(); + $placement_row = current_row(); + if ( ! empty( $placement_device ) ) { + $placement_context['device'] = $placement_device; + } + if ( ! empty( $placement_row ) ) { + $placement_context['row'] = $placement_row; + } +} + ?>
+ data-section="get_section_id() ); ?>" + data-item-id="get_id() ); ?>"> render_css(); - current_component()->render_component(); + $_component->render_css(); + $_component->render_component(); ?> diff --git a/header-footer-grid/templates/components/component-footer-sidebar.php b/header-footer-grid/templates/components/component-footer-sidebar.php index 7bffeb3a2a..3656e9c35e 100644 --- a/header-footer-grid/templates/components/component-footer-sidebar.php +++ b/header-footer-grid/templates/components/component-footer-sidebar.php @@ -10,7 +10,14 @@ namespace HFG; -$_id = current_component()->get_id(); +$_component = current_component(); + +// Bail when the current component cannot be resolved on the current builder. +if ( ! $_component instanceof Core\Components\Abstract_Component ) { + return; +} + +$_id = $_component->get_id(); if ( is_active_sidebar( $_id ) ) { ?> @@ -20,7 +27,7 @@
-

get_property( 'label' ) ); ?>

+

get_property( 'label' ) ); ?>

get_property( 'label' ) ) + esc_attr( $_component->get_property( 'label' ) ) ) ) ); diff --git a/header-footer-grid/templates/components/component-logo.php b/header-footer-grid/templates/components/component-logo.php index c3ac4c0684..7a371697a5 100644 --- a/header-footer-grid/templates/components/component-logo.php +++ b/header-footer-grid/templates/components/component-logo.php @@ -12,7 +12,14 @@ use HFG\Core\Builder\Header as HeaderBuilder; use HFG\Core\Components\Logo; -$_id = current_component( HeaderBuilder::BUILDER_NAME )->get_id(); +$_component = current_component( HeaderBuilder::BUILDER_NAME ); + +// Bail when the current component cannot be resolved on the current builder. +if ( ! $_component instanceof \HFG\Core\Components\Abstract_Component ) { + return; +} + +$_id = $_component->get_id(); $device = current_device( HeaderBuilder::BUILDER_NAME ); $show_name = component_setting( Logo::SHOW_TITLE ); diff --git a/header-footer-grid/templates/components/component-nav-footer.php b/header-footer-grid/templates/components/component-nav-footer.php index 8e05102679..f9c40ae9a5 100644 --- a/header-footer-grid/templates/components/component-nav-footer.php +++ b/header-footer-grid/templates/components/component-nav-footer.php @@ -19,6 +19,13 @@ $container_classes[] = 'm-style'; } +$menu_id = NavFooter::COMPONENT_ID; +$device = current_device(); +$row = current_row(); +if ( ! empty( $device ) && ! empty( $row ) ) { + $menu_id .= '-' . $device . '-' . $row; +} + ?>