Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
8 changes: 8 additions & 0 deletions src/components/pro-control/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ const LABELS = {
<li>{ __( 'Override styles while keeping them synced', i18n ) }</li>
</ul>,
},
'design-library-saved-patterns': {
title: __( 'Design Library Saved Patterns', i18n ),
description: <ul>
<li>{ __( 'Save entire block layouts in a click', i18n ) }</li>
<li>{ __( 'Apply styling options to each saved pattern', i18n ) }</li>
<li>{ __( 'Import and export your custom patterns across sites', i18n ) }</li>
</ul>,
},
}

const ProControl = props => {
Expand Down
15 changes: 13 additions & 2 deletions src/design-library/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const fetchDesignLibrary = async ( forceReset = false, version = '', type
}
}

return designLibrary[ type ]?.[ version || LATEST_API_VERSION ] ?? designLibrary[ type ]
return designLibrary[ type ]?.[ version || LATEST_API_VERSION ] ?? designLibrary[ type ] ?? {}
}

export const fetchDesign = async designId => {
Expand All @@ -61,6 +61,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 designLibrary = await fetchDesignLibrary( reset, LATEST_API_VERSION, type )

if ( designLibrary.wp_remote_get_error || designLibrary.content_error ) {
Expand All @@ -82,8 +92,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 @@ -289,6 +289,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
134 changes: 134 additions & 0 deletions src/design-library/saved-patterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Saved Patterns in the Design Library

Premium-only feature that lets users save Stackable Columns layouts to a personal design library, then reuse them from the **Saved** tab in the Design Library modal.

Free plugin provides the Saved tab UI and integration hooks. Premium plugin provides storage, REST API, save/edit/delete UI, and import/export.

## Architecture

```
Editor (block ⋮ menu) Design Library modal (Saved tab) Stackable Settings (Import/Export)
│ │ │
▼ ▼ ▼
SaveModal / edit UI lazy-components/design-library welcome/import-export
│ │ │
└──────────────┬─────────────────────┴──────────────────┬───────────────┘
▼ ▼
pro__premium_only/src/design-library/user-patterns.js
REST stackable/v3/user_patterns → CPT stk-user-pattern (PHP)
```

## Storage

Patterns are stored as a **private custom post type** `stk-user-pattern` (not public, hidden from admin UI and REST index).

| Field | CPT mapping |
|-------|-------------|
| `id` (slug) | `post_name` |
| `label` | `post_title` |
| `description` | `post_excerpt` |
| `category` | post meta `_stk_pattern_category` |
| `template` | `post_content` (serialized block markup) |

**Create** writes all fields including `post_content`.

**Update** (`PUT /user_patterns/{slug}`) changes metadata only — label, slug, description, category. It does **not** replace `post_content`, so editing a pattern never overwrites the saved block markup.

## REST API (`stackable/v3`)

| Method | Route | Capability | Purpose |
|--------|-------|------------|---------|
| `GET` | `/user_patterns` | `edit_posts` | List patterns for the Saved tab |
| `POST` | `/user_patterns` | `edit_theme_options` | Create pattern |
| `PUT` | `/user_patterns/{slug}` | `edit_theme_options` | Update metadata |
| `DELETE` | `/user_patterns` | `edit_theme_options` | Bulk delete by slug |
| `POST` | `/user_patterns/import` | `manage_options` | Bulk import from export file |

PHP: `pro__premium_only/src/design-library/user-patterns.php`

JS client: `pro__premium_only/src/design-library/user-patterns.js` (session cache, invalidated on writes)

## Capabilities & UI gating

| Action | Capability | Where gated |
|--------|------------|-------------|
| View / insert saved patterns | `edit_posts` | REST `GET` |
| Save, edit, delete | `edit_theme_options` | REST writes; block ⋮ menu; edit/delete buttons in Saved tab |
| Import patterns (settings page) | `manage_options` | REST import endpoint |

The Design Library modal calls `useCanManageUserPatterns()` once on open and passes `canManageUserPatterns` through `DesignLibraryContext` so tab switches do not re-check capability per card.

Outside the modal (e.g. block ⋮ **Save to Design Library**), `useCanManageUserPatterns` in `src/hooks/use-can-manage-user-patterns.js` is used directly.

## Saving a pattern

1. User selects a **Stackable Columns** block and chooses **Save to Design Library** from the block ⋮ menu.
2. `pro__premium_only/src/plugins/design-library-save-pattern-button/` opens `SaveModal`.
3. On save, the modal serializes the selected blocks via `@wordpress/blocks` `serialize()` and `POST`s to `/user_patterns`.
4. `stackable.design-library.pattern-saved` action fires; a notice points the user to the Design Library Saved tab.

Only Columns blocks are allowed. Other Stackable blocks show the menu item disabled.

## Using saved patterns

1. User opens the Design Library modal and switches to the **Saved** tab.
2. `getDesigns({ type: 'saved' })` in `src/design-library/index.js` calls the `stackable.design-library.get-saved-designs` filter (premium registers `fetchSavedPatterns`).
3. Patterns are **lazy-loaded** on first visit to the Saved tab — not on editor boot.
4. User selects patterns and clicks **Add Designs** to insert them like cloud patterns.

Free users see a Pro upsell on the Saved tab instead of the list.

## Edit & delete

- **Edit** — pencil button on each saved item (premium). Opens `SaveModal` in `UPDATE` mode; only label, slug, category, and description are sent.
- **Delete** — bulk delete in the modal footer when items are selected on the Saved tab.

Both are hidden when `canManageUserPatterns` is false.

## Import / export

Stackable Settings → Import/Export → Design Library tab.

- **Export** — patterns fetched via `stackable.design-library.fetch-saved-patterns` filter; user selects which to include in the JSON file.
- **Import** — selected patterns from the JSON file are sent through `stackable.admin-settings.import-export.handle-import` → `importUserPatterns()` → `POST /user_patterns/import` with `ADD`, `SKIP`, or `OVERWRITE` duplicate handling.

Free wiring: `src/welcome/import-export/design-library.js`
Premium UI + import handler: `pro__premium_only/src/welcome/import-export/design-library.js`

## Key files

### Free plugin

| File | Role |
|------|------|
| `src/design-library/index.js` | `getDesigns({ type: 'saved' })` filter hook |
| `src/design-library/init.php` | `type=saved` REST route; `stackable_design_library_saved_patterns` filter |
| `src/lazy-components/design-library/` | Modal, Saved tab, lazy fetch |
| `src/hooks/use-can-manage-user-patterns.js` | Capability check hook |
| `src/welcome/import-export/design-library.js` | Import/export tab loader |

### Premium plugin (`pro__premium_only/`)

| File | Role |
|------|------|
| `src/design-library/user-patterns.php` | CPT registration, REST routes, sanitization |
| `src/design-library/user-patterns.js` | REST client + cache |
| `src/design-library/index.js` | Registers `get-saved-designs` and `fetch-saved-patterns` filters |
| `src/block-components/design-library/save-modal.js` | Save / edit modal |
| `src/block-components/design-library/index.js` | Edit button, bulk delete filters |
| `src/block-components/design-library/store.js` | `stackable/design-library-saved-patterns` data store |
| `src/plugins/design-library-save-pattern-button/` | Block ⋮ menu entry |

## WordPress hooks

| Hook | Purpose |
|------|---------|
| `stackable.design-library.get-saved-designs` | Premium returns pattern list for `getDesigns()` |
| `stackable.design-library.fetch-saved-patterns` | Premium returns promise for import/export lazy load |
| `stackable_design_library_saved_patterns` | PHP filter; premium returns CPT data for `init.php` saved route |
| `stackable.design-library.pattern-label-actions` | Premium renders edit button per saved item |
| `stackable.design-library.footer-selection-actions` | Premium renders bulk delete button |
| `stackable.design-library.saved-patterns-loaded` | Fired when patterns list updates (syncs store + modal) |
| `stackable.design-library.pattern-saved` | Fired after a successful save from the block menu |
1 change: 1 addition & 0 deletions src/hooks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ export * from './use-global-block-layout-defaults'
export * from './use-block-color-schemes'
export * from './use-preset-controls'
export * from './use-block-style-context'
export * from './use-can-manage-user-patterns'
52 changes: 52 additions & 0 deletions src/hooks/use-can-manage-user-patterns.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { currentUserHasCapability } from '~stackable/util'

import { useEffect, useState } from '@wordpress/element'
import { useSelect } from '@wordpress/data'

export const USER_PATTERN_MANAGE_CAPABILITY = 'edit_theme_options'

let cachedCanManage = null
let inflightPromise = null

export const useCanManageUserPatterns = () => {
const userId = useSelect( select => select( 'core' ).getCurrentUser()?.id, [] )
const [ canManage, setCanManage ] = useState( () => cachedCanManage ?? false )

useEffect( () => {
if ( ! userId ) {
setCanManage( false )
return
}

if ( cachedCanManage !== null ) {
setCanManage( cachedCanManage )
return
}

if ( ! inflightPromise ) {
inflightPromise = currentUserHasCapability( USER_PATTERN_MANAGE_CAPABILITY ).then( hasCapability => {
cachedCanManage = !! hasCapability
inflightPromise = null
return cachedCanManage
} ).catch( () => {
cachedCanManage = false
inflightPromise = null
return false
} )
}

let isMounted = true

inflightPromise.then( hasCapability => {
if ( isMounted ) {
setCanManage( hasCapability )
}
} )

return () => {
isMounted = false
}
}, [ userId ] )

return canManage
}
Loading
Loading