Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8966428
init
kaeizen Oct 14, 2025
048572d
fixes to coderabbit's qa, prevent previews from having focus
kaeizen Oct 15, 2025
d727100
fix filter
kaeizen Oct 15, 2025
9dbd54d
fix transition on buttons, fix re-renders
kaeizen Oct 15, 2025
853a1dc
fix to coderabbit's qa
kaeizen Oct 15, 2025
f7eeec0
handle failed export, show pattern actions for non-hover
kaeizen Oct 15, 2025
1097476
tweaked location of pro note in the design library
Nov 10, 2025
708b376
adjusted upsell text
Nov 11, 2025
bd61624
Merge branch 'develop' into feat/design-library-saving-patterns
bfintal Jul 11, 2026
9d93ecb
fixed note when no saved designs yet
bfintal Jul 12, 2026
d9b98d7
fixed notice showing also in other tabs
bfintal Jul 12, 2026
bc044f8
fixed event bubbling
bfintal Jul 12, 2026
7463b98
speed ups to design library
bfintal Jul 12, 2026
5fc5abb
fixed performance issues in design library, designs no longer saved a…
bfintal Jul 13, 2026
ea64fec
added md file on how saving patterns work
bfintal Jul 13, 2026
3956996
Merge branch 'develop' into feat/design-library-saving-patterns
bfintal Jul 18, 2026
1cc144c
updated upsell message
bfintal Jul 18, 2026
f81bfc6
test: add Design Library e2e coverage for free library flows
bfintal Jul 29, 2026
b0aced2
fix: drop removed patterns in the selection
Arukuen Jul 30, 2026
3cc4f7c
fix: use date modified as additional cache key to avoid stale patterns
Arukuen Jul 30, 2026
ead2679
fix: also check import in asyncImports
Arukuen Jul 30, 2026
fb4ee45
fix: add additional guard for json shape
Arukuen Jul 30, 2026
747c63e
fix: recognize button so it wont bubble up
Arukuen Jul 30, 2026
d9c4f65
fix: only wait for the panel's loading spinner
Arukuen Aug 4, 2026
521fca6
Merge branch 'develop' into feat/design-library-saving-patterns
bfintal Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ At the minimum, e2e testing should test the following:
- Blocks are present in the Block Editor and working
- Blocks are present in the Site Editor
- Global settings and functionality is working
- Design Library opens, loads patterns/pages, filters, and inserts designs
- Dynamic Content resolves on the frontend (Premium codebase only)
- Perform the above tests in all supported lower WordPress versions

Expand Down
218 changes: 218 additions & 0 deletions e2e/tests/design-library.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { Page } from '@playwright/test'
import { test, expect } from 'e2e/test-utils'

const dismissTourIfPresent = async ( page: Page ) => {
const tourClose = page.locator( '.ugb-tour-modal .components-modal__header button' ).first()
if ( await tourClose.isVisible().catch( () => false ) ) {
await tourClose.click()
}
}

const openDesignLibrary = async ( page: Page ) => {
await dismissTourIfPresent( page )

const designLibraryButton = page.locator( '.ugb-insert-library-button' )
await expect( designLibraryButton ).toBeVisible()
await designLibraryButton.click()

const modal = page.locator( '.ugb-modal-design-library' )
await expect( modal ).toBeVisible()

// The design-library tour may open after the modal.
await dismissTourIfPresent( page )
}

const waitForDesignsToLoad = async ( page: Page ) => {
const designsPanel = page.locator( '.ugb-modal-design-library__designs' )
await expect( designsPanel ).toBeVisible()

// Only wait for the panel's loading spinner. Each design card keeps its own
// preview spinner in the DOM after loading and hides it with CSS.
await expect( designsPanel.locator( ':scope > .components-spinner' ) ).toHaveCount( 0, { timeout: 60_000 } )
await expect( page.locator( '.ugb-design-library-item' ).first() ).toBeVisible( { timeout: 60_000 } )
}

test.describe( 'Design Library', () => {
let pid = null

test.beforeEach( async ( { editor, admin } ) => {
await admin.createNewPost( { title: 'Design Library Test' } )
await editor.saveDraft()
const postQuery = new URL( editor.page.url() ).search
pid = new URLSearchParams( postQuery ).get( 'post' )
} )

test.afterEach( async ( { requestUtils } ) => {
await requestUtils.deletePost( pid )
} )

test( 'opens and loads pattern designs without showing a blank library', async ( {
page,
} ) => {
const libraryResponse = page.waitForResponse( response => {
const url = decodeURIComponent( response.url() )
return url.includes( '/stackable/v2/design_library/patterns' ) && response.ok()
} )

await openDesignLibrary( page )
await libraryResponse
await waitForDesignsToLoad( page )

const modal = page.locator( '.ugb-modal-design-library' )
await expect( modal.getByRole( 'heading', { name: 'Stackable Design Library' } ) ).toBeVisible()
await expect( modal.locator( '.stk-design-library-tabs' ) ).toContainText( 'Patterns' )
await expect( modal.locator( '.stk-design-library-tabs' ) ).toContainText( 'Pages' )

const designItems = page.locator( '.ugb-design-library-item' )
await expect( designItems.first() ).toBeVisible()
expect( await designItems.count() ).toBeGreaterThan( 0 )

// No fetch/parse error should be shown in the designs panel.
await expect( page.locator( '.ugb-modal-design-library__designs' ).getByText( 'An error has occurred:' ) ).toHaveCount( 0 )
} )

test( 'lazy-renders design previews for visible items', async ( {
page,
} ) => {
await openDesignLibrary( page )
await waitForDesignsToLoad( page )

const visibleItems = page.locator( '.ugb-design-library-item:not(.ugb--is-hidden)' )
await expect( visibleItems.first() ).toBeVisible()

// First batch of designs should mount preview hosts (IntersectionObserver + memoized list).
const previewHosts = page.locator( '.ugb-design-library-item:not(.ugb--is-hidden) .stk-block-design__host' )
await expect( previewHosts.first() ).toBeVisible( { timeout: 60_000 } )
expect( await previewHosts.count() ).toBeGreaterThan( 0 )

const designsPanel = page.locator( '.ugb-modal-design-library__designs' )
const beforeScrollCount = await previewHosts.count()

// Scroll further down the virtualized list so more previews can mount.
await designsPanel.evaluate( el => {
el.scrollTop = el.scrollHeight
} )

await expect.poll( async () => {
return page.locator( '.ugb-design-library-item:not(.ugb--is-hidden) .stk-block-design__host' ).count()
}, { timeout: 30_000 } ).toBeGreaterThanOrEqual( beforeScrollCount )
} )

test( 'filters patterns by category and plan', async ( {
page,
} ) => {
await openDesignLibrary( page )
await waitForDesignsToLoad( page )

const sidebar = page.locator( '.ugb-modal-design-library__filters' )
await expect( sidebar.locator( '.stk-design-library__sidebar-item' ).first() ).toBeVisible()

// Click the first non-"All" category.
const categoryItems = sidebar.locator( '.stk-design-library__sidebar-item' )
const categoryCount = await categoryItems.count()
expect( categoryCount ).toBeGreaterThan( 1 )

await categoryItems.nth( 1 ).click()
await expect( categoryItems.nth( 1 ) ).toHaveClass( /is-active/ )
await waitForDesignsToLoad( page )

const filteredCount = await page.locator( '.ugb-design-library-item' ).count()
expect( filteredCount ).toBeGreaterThan( 0 )

// Filter to free designs only.
const planDropdown = page.locator( '.stk-design-library__header-settings' ).getByRole( 'button' ).filter( { hasText: /All|Free|Premium/ } )
if ( await planDropdown.isVisible().catch( () => false ) ) {
await planDropdown.click()
await page.locator( '.stk-design-library__plan-dropdown' ).getByRole( 'button', { name: 'Free', exact: true } ).click()
await waitForDesignsToLoad( page )
await expect( page.locator( '.ugb-design-library-item.ugb--is-premium' ) ).toHaveCount( 0 )
}
} )

test( 'switches to Pages tab and loads page designs', async ( {
page,
} ) => {
const pagesResponse = page.waitForResponse( response => {
const url = decodeURIComponent( response.url() )
return url.includes( '/stackable/v2/design_library/pages' ) && response.ok()
} )

await openDesignLibrary( page )
await waitForDesignsToLoad( page )

await page.locator( '.stk-design-library-tabs' ).getByRole( 'button', { name: 'Pages' } ).click()
await pagesResponse
await waitForDesignsToLoad( page )

await expect( page.locator( '.stk-design-library__item-pages' ) ).toBeVisible()
await expect( page.locator( '.ugb-design-library-item' ).first() ).toBeVisible()

// Pages use an immediate Insert action instead of multi-select footer.
await expect( page.locator( '.ugb-modal-design-library__footer' ) ).toHaveCount( 0 )
await expect( page.locator( '.ugb-design-library-item' ).first().getByRole( 'button', { name: 'Insert' } ) ).toBeVisible()
} )

test( 'refresh reloads the library', async ( {
page,
} ) => {
await openDesignLibrary( page )
await waitForDesignsToLoad( page )

const refreshResponse = page.waitForResponse( response => {
const url = decodeURIComponent( response.url() )
return url.includes( '/stackable/v2/design_library/patterns/reset' ) && response.ok()
} )

await page.locator( '.ugb-modal-design-library__refresh' ).click()
await refreshResponse
await waitForDesignsToLoad( page )

await expect( page.locator( '.ugb-design-library-item' ).first() ).toBeVisible()
} )

test( 'selects a free pattern and inserts it into the editor', async ( {
page,
editor,
} ) => {
await openDesignLibrary( page )
await waitForDesignsToLoad( page )

// Prefer free designs when the plan filter is available.
const planDropdown = page.locator( '.stk-design-library__header-settings' ).getByRole( 'button' ).filter( { hasText: /All|Free|Premium/ } )
if ( await planDropdown.isVisible().catch( () => false ) ) {
await planDropdown.click()
await page.locator( '.stk-design-library__plan-dropdown' ).getByRole( 'button', { name: 'Free', exact: true } ).click()
await waitForDesignsToLoad( page )
}

const firstDesign = page.locator( '.ugb-design-library-item:not(.ugb--is-premium)' ).first()
await expect( firstDesign ).toBeVisible()
await firstDesign.click()
await expect( firstDesign ).toHaveClass( /ugb--is-toggled/ )

const addDesigns = page.locator( '.ugb-modal-design-library__footer .ugb-modal-design-library__add-multi' )
await expect( addDesigns ).toBeEnabled()
await addDesigns.click()

await expect( page.locator( '.ugb-modal-design-library' ) ).toHaveCount( 0, { timeout: 60_000 } )

// Inserted designs replace/remove the Design Library placeholder and add real blocks.
await expect( editor.canvas.locator( '[data-type^="stackable/"]' ).first() ).toBeVisible( { timeout: 60_000 } )
} )

test( 'exposes style options for pattern customization', async ( {
page,
} ) => {
await openDesignLibrary( page )
await waitForDesignsToLoad( page )

const styleOptions = page.locator( '.ugb-modal-design-library__style-options' )
await expect( styleOptions.getByRole( 'heading', { name: 'Style Options' } ) ).toBeVisible()
await expect( page.locator( '.ugb-modal-design-library__enable-background' ) ).toBeVisible()

const backgroundToggle = page.locator( '.ugb-modal-design-library__enable-background input' )
const wasChecked = await backgroundToggle.isChecked()
await page.locator( '.ugb-modal-design-library__enable-background' ).click()
await expect( backgroundToggle ).toHaveJSProperty( 'checked', ! wasChecked )
} )
} )
16 changes: 10 additions & 6 deletions src/block/design-library/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ const Edit = props => {
const spacingSize = ! presetMarks || ! Array.isArray( presetMarks ) ? 120 : presetMarks[ presetMarks.length - 2 ].value

// Replaces the current block with a block made out of attributes.
const createBlockWithAttributes = async ( category, blockName, attributes, innerBlocks, substituteBlocks, parentClientId ) => {
const createBlockWithAttributes = async ( category, blockName, attributes, innerBlocks, substituteBlocks, parentClientId, type ) => {
const disabledBlocks = settings.stackable_block_states || {} // eslint-disable-line camelcase

// Recursively substitute core blocks to disabled Stackable blocks
Expand Down Expand Up @@ -207,7 +207,7 @@ const Edit = props => {
innerBlocks = block[ 0 ].innerBlocks

const isDesignLibraryDevMode = devMode && localStorage.getItem( 'stk__design_library__dev_mode' ) === '1'
if ( ! isDesignLibraryDevMode ) {
if ( ! isDesignLibraryDevMode && type !== 'saved' ) {
if ( category !== 'Header' ) {
if ( ! parentClientId && attributes.hasBackground ) {
attributes.blockMargin = {
Expand Down Expand Up @@ -255,14 +255,16 @@ const Edit = props => {
const blocks = []

for ( const blockDesign of designs ) {
const { designData, category } = blockDesign
const {
designData, category, type,
} = blockDesign

for ( const patterns of designData ) {
const {
name, attributes, innerBlocks,
} = patterns
if ( name && attributes ) {
const block = await createBlockWithAttributes( category, name, applyFilters( 'stackable.design-library.attributes', attributes ), innerBlocks || [], substituteBlocks, parentClientId )
const block = await createBlockWithAttributes( category, name, applyFilters( 'stackable.design-library.attributes', attributes ), innerBlocks || [], substituteBlocks, parentClientId, type )
blocks.push( block )
} else {
console.error( 'Design library selection failed: No block data found' ) // eslint-disable-line no-console
Expand Down Expand Up @@ -336,14 +338,16 @@ const Edit = props => {

_designs.forEach( design => {
const {
designData, blocksForSubstitution, category,
designData, blocksForSubstitution, category, type: designType,
} = design

if ( blocksForSubstitution.size ) {
disabledBlocks = disabledBlocks.union( blocksForSubstitution )
}

designs.push( { designData, category } )
designs.push( {
designData, category, type: designType,
} )
} )

designsRef.current = designs
Expand Down
4 changes: 4 additions & 0 deletions src/components/pro-control/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ const LABELS = {
title: __( 'Design Once, Update Everywhere', i18n ),
description: __( 'Sync block styles site-wide so one change updates every instance—no more copy-paste or hunting down inconsistencies.', i18n ),
},
'design-library-saved-patterns': {
title: __( 'Reuse Your Best Layouts', i18n ),
description: __( 'Save polished block layouts once, then drop them into any page, or export them to reuse across sites.', i18n ),
},
}

const ProControl = props => {
Expand Down
13 changes: 12 additions & 1 deletion src/design-library/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ export const getDesigns = async ( {
reset = false,
type = 'patterns',
} ) => {
if ( type === 'saved' ) {
const result = applyFilters( 'stackable.design-library.get-saved-designs', [], { reset } )

if ( result && typeof result.then === 'function' ) {
return await result
}

return Array.isArray( result ) ? result : []
}

const library = await fetchDesignLibrary( reset, LATEST_API_VERSION, type )

if ( ! library || typeof library !== 'object' ) {
Expand Down Expand Up @@ -98,8 +108,9 @@ export const filterDesigns = async ( {
library = [],
plan: isPlan = '',
category: isCategory = '',
type = 'patterns',
} ) => {
if ( isPlan ) {
if ( isPlan && type !== 'saved' ) {
library = library.filter( ( { plan } ) => plan === isPlan )
}

Expand Down
4 changes: 4 additions & 0 deletions src/design-library/init.php
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@ public function get_design_library( $request ) {
$this->delete_cache();
}

if ( $type === 'saved' ) {
return rest_ensure_response( apply_filters( 'stackable_design_library_saved_patterns', array() ) );
}

return rest_ensure_response( $this->get_design_library_from_cloud( $type ) );
}

Expand Down
Loading
Loading