Skip to content
Merged
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
10 changes: 8 additions & 2 deletions docs/research/12-elementor-parity-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,14 @@ icon/icon-box/list ✓ MA libreria icone gestita (carica/SVG set/selettore) ⚠
1. **Theme builder template system** (header/footer/archive/single/404 come template con condizioni di
display): le PRIMITIVE c esistono gi (wp-\* + nav-menu + condizioni del builder menu), manca il CRUD UI
dei template-part e le conditions. È il pezzo che rende il confronto possibile con Elementor Pro.
2. **WooCommerce** (loro pagina lista ~15 widget prodotto free/pro): 0 coverage. Il gap singolo
più grande. Sottoinsieme ragionato: product-grid, add-to-cart, gallery, meta — su islands runtime.
2. **WooCommerce** — sottinsieme v1 FATTO (feat/woo-blocks): product-grid (columns closed-set,
category slug, on-sale, orderby), product-featured (per productId), product-categories — tutti
statici, registrati solo con class_exists('WooCommerce'). L'add-to-cart usa l'URL nativo
Woo ?add-to-cart=ID: zero JS custom, niente island (l'island restava per la galleria, non fatta
in v1). Lezione dev-site: la pagina carrello creata da Woo e' il blocco JS-rendered -> per il
test flow classico si e' messo [woocommerce_cart] nel contenuto; hello theme non stampa le
notices fuori dai template Woo; wc_create_product esiste solo in admin (CLI: usare
WC_Product_Simple->save()); nell'handler reale il hook e' wp_loaded priorita' 20.
3. **Icon library gestita** — FATTA (feat/icon-library): 1838 Lucide (ISC) via manifest JSON nel
plugin, endpoint GET/POST blocky/v1/icons, upload SVG con sanitizer DOM allowlist proprio
(wp_kses_svg NON esiste nel core: verificare sempre prima di citarlo), controllo inspector
Expand Down
117 changes: 117 additions & 0 deletions e2e/woo.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { execFileSync } from 'node:child_process';
import { expect, test } from '@playwright/test';

import {
builderPreviewFrame,
ensureCanvasBlock,
loginToBuilder,
selectCanvasBlock,
} from './helpers/builder';

/*
* WooCommerce subset (matrix item 5). All product markup is static and
* add-to-cart uses WooCommerce's native ?add-to-cart URL — the test follows
* the real click path into the cart page, no custom JS involved.
*/

const SITE = process.env.BLOCKY_E2E_SITE ?? 'http://192.168.1.8:8888';
const CONTAINER = process.env.BLOCKY_E2E_CONTAINER ?? 'local-wp-wp-1';

function wp(args: string[]): string {
return execFileSync('docker', ['exec', CONTAINER, 'wp', ...args, '--allow-root'], {
encoding: 'utf-8',
}).trim();
}

let postId = 0;
let widgetTeeId = 0;
let hoodieId = 0;

test.beforeAll(() => {
wp(['eval-file', '/tmp/woo-products2.php']);
const out = wp([
'eval',
'foreach (array("Widget Tee","Panel Hoodie") as $n) { $p = get_page_by_title($n, OBJECT, "product"); echo ($p ? $p->ID : 0), " "; }',
]);
const ids = out.split(/\s+/).map(Number);
widgetTeeId = ids[0] ?? 0;
hoodieId = ids[1] ?? 0;
postId = Number(
wp([
'post',
'create',
'--post_title=Woo E2E',
'--post_type=page',
'--post_status=publish',
'--porcelain',
])
);
});

test.afterAll(() => {
if (postId) wp(['post', 'delete', String(postId), '--force']);
});

function toolbar(page: import('@playwright/test').Page) {
return page.locator('header');
}

async function saveDocument(page: import('@playwright/test').Page) {
await expect(toolbar(page).getByRole('button', { name: 'Save', exact: true })).toBeEnabled({
timeout: 5000,
});
const saved = page.waitForResponse(
(r) =>
new URL(r.url()).pathname.endsWith('/documents/' + postId) && r.request().method() === 'POST',
{ timeout: 30000 }
);
await toolbar(page).getByRole('button', { name: 'Save', exact: true }).click();
expect((await saved).status()).toBe(200);
}

test.describe('WooCommerce blocks', () => {
test('product grid renders products and the real add-to-cart click reaches the cart', async ({
page,
}) => {
test.skip(widgetTeeId === 0, 'products missing');
await loginToBuilder(page, { postId });
await ensureCanvasBlock(page, 'bky/product-grid');

const frame = builderPreviewFrame(page);
await expect(frame.getByText('Widget Tee').first()).toBeVisible();
await expect(frame.locator('a[href*="add-to-cart="]').first()).toBeVisible();

await saveDocument(page);

await page.goto(SITE + '/?page_id=' + postId, { waitUntil: 'networkidle' });
await expect(page.getByRole('link', { name: 'Widget Tee' }).first()).toBeVisible();
await page
.locator('article', { hasText: 'Widget Tee' })
.getByRole('link', { name: 'Add to cart' })
.click();
// Woo's default add-to-cart does not redirect (and the hello theme prints
// notices only inside Woo templates), so the classic verification point is
// the cart page itself — the clicked product must be listed there.
await page.goto(SITE + '/cart/', { waitUntil: 'networkidle' });
await expect(page.getByRole('link', { name: 'Widget Tee' })).toBeVisible();
});

test('featured product shows the product selected by id', async ({ page }) => {
test.skip(hoodieId === 0, 'products missing');
await loginToBuilder(page, { postId });
await ensureCanvasBlock(page, 'bky/product-featured');
await selectCanvasBlock(page, 'bky/product-featured');

await page.locator('aside label:has-text("Product ID") input').fill(String(hoodieId));

const frame = builderPreviewFrame(page);
await expect(frame.getByRole('heading', { name: 'Panel Hoodie', level: 2 })).toBeVisible({
timeout: 10000,
});

await saveDocument(page);

await page.goto(SITE + '/?page_id=' + postId, { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: 'Panel Hoodie', level: 2 })).toBeVisible();
});
});
2 changes: 1 addition & 1 deletion packages/core-plugin/phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ parameters:

-
message: "#^Parameter \\$variants of class Blocky\\\\Core\\\\Blocks\\\\BlockDefinition constructor expects array\\<string, array\\<string, string\\>\\>, array\\<string, array\\<int, string\\>\\> given\\.$#"
count: 1
count: 2
path: src/Blocks/Registry.php

-
Expand Down
1 change: 1 addition & 0 deletions packages/core-plugin/phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ parameters:
- tests/phpstan-bootstrap.php
scanFiles:
- tests/phpstan/wp-cli-stub.php
- tests/phpstan/woocommerce-stub.php
ignoreErrors: []
105 changes: 105 additions & 0 deletions packages/core-plugin/src/Blocks/Registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@

use Blocky\Core\Blocks\Renderer\BlockRendererInterface;
use Blocky\Core\Blocks\Renderers\HeadingRenderer;
use Blocky\Core\Blocks\Renderers\ProductGridRenderer;
use Blocky\Core\Blocks\Renderers\ProductFeaturedRenderer;
use Blocky\Core\Blocks\Renderers\ProductCategoriesRenderer;
use Blocky\Core\Blocks\Renderers\SectionRenderer;
use Blocky\Core\Blocks\Renderers\TextRenderer;
use Blocky\Core\Blocks\Renderers\ButtonRenderer;
Expand Down Expand Up @@ -279,6 +282,12 @@ public function registerCoreBlocks(): void
$this->register(self::makeDataField());
$this->register(self::makeWpHook());
$this->register(self::makeThemeToggle());

if (class_exists('WooCommerce')) {
$this->register(self::makeProductGrid());
$this->register(self::makeProductFeatured());
$this->register(self::makeProductCategories());
}
}

/**
Expand Down Expand Up @@ -2102,6 +2111,102 @@ private static function makePostsGrid(): BlockDefinition
);
}

private static function makeProductGrid(): BlockDefinition
{
return new BlockDefinition(
type: 'bky/product-grid',
label: self::tr('Product Grid'),
category: 'basic',
description: self::tr('Grid of WooCommerce products with static add-to-cart links.'),
keywords: ['shop', 'woo', 'products'],
icon: '\u{1F6D2}',
schema: [
'type' => 'object',
'properties' => [
'number' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 24, 'default' => 6],
'category' => ['type' => 'string', 'default' => ''],
'orderby' => ['type' => 'string', 'enum' => ['date', 'price', 'popularity', 'title'], 'default' => 'date'],
'onSaleOnly' => ['type' => 'boolean', 'default' => false],
'showPrice' => ['type' => 'boolean', 'default' => true],
'showAddToCart' => ['type' => 'boolean', 'default' => true],
'columns' => ['type' => 'string', 'enum' => ['2', '3', '4'], 'default' => '3'],
],
],
variants: [
'columns' => ['2' => 'grid-cols-1 md:grid-cols-2', '3' => 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', '4' => 'grid-cols-1 md:grid-cols-2 xl:grid-cols-4'],
],
renderer: new ProductGridRenderer(),
editorConfig: self::localizeEditorConfig([
'tabs' => [
['id' => 'content', 'label' => 'Content', 'controls' => [
['id' => 'number', 'type' => 'number', 'label' => 'Products', 'min' => 1, 'max' => 24],
['id' => 'category', 'type' => 'text', 'label' => 'Category Slug'],
['id' => 'orderby', 'type' => 'select', 'label' => 'Order By', 'options' => [['date', 'Newest'], ['price', 'Price'], ['popularity', 'Popularity'], ['title', 'Title']]],
['id' => 'onSaleOnly', 'type' => 'toggle', 'label' => 'On Sale Only'],
['id' => 'showPrice', 'type' => 'toggle', 'label' => 'Show Price'],
['id' => 'showAddToCart', 'type' => 'toggle', 'label' => 'Add to Cart Button'],
]],
['id' => 'layout', 'label' => 'Layout', 'controls' => [
['id' => 'columns', 'type' => 'variant', 'label' => 'Columns', 'variantKey' => 'columns'],
]],
],
]),
);
}
private static function makeProductFeatured(): BlockDefinition
{
return new BlockDefinition(
type: 'bky/product-featured',
label: self::tr('Featured Product'),
category: 'basic',
description: self::tr('Spotlight one WooCommerce product with image, price and add-to-cart.'),
keywords: ['shop', 'woo', 'product'],
icon: '\u{2B50}',
schema: [
'type' => 'object',
'properties' => [
'productId' => ['type' => 'integer', 'minimum' => 0, 'default' => 0],
'imageSide' => ['type' => 'string', 'enum' => ['left', 'right'], 'default' => 'left'],
],
],
variants: [],
renderer: new ProductFeaturedRenderer(),
editorConfig: self::localizeEditorConfig([
'tabs' => [
['id' => 'content', 'label' => 'Content', 'controls' => [
['id' => 'productId', 'type' => 'number', 'label' => 'Product ID', 'min' => 0],
['id' => 'imageSide', 'type' => 'select', 'label' => 'Image Side', 'options' => [['left', 'Left'], ['right', 'Right']]],
]],
],
]),
);
}
private static function makeProductCategories(): BlockDefinition
{
return new BlockDefinition(
type: 'bky/product-categories',
label: self::tr('Product Categories'),
category: 'basic',
description: self::tr('Links to WooCommerce product categories with counts.'),
keywords: ['shop', 'woo', 'categories'],
icon: '\u{1F3F7}',
schema: [
'type' => 'object',
'properties' => [
'number' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 30, 'default' => 8],
],
],
variants: [],
renderer: new ProductCategoriesRenderer(),
editorConfig: self::localizeEditorConfig([
'tabs' => [
['id' => 'content', 'label' => 'Content', 'controls' => [
['id' => 'number', 'type' => 'number', 'label' => 'Maximum', 'min' => 1, 'max' => 30],
]],
],
]),
);
}
private static function makeFeaturedPosts(): BlockDefinition
{
return new BlockDefinition(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php
/**
* Product category list renderer (WooCommerce-gated at registration).
*
* @package Blocky\Core\Blocks\Renderers
*/

declare( strict_types=1 );

namespace Blocky\Core\Blocks\Renderers;

defined( 'ABSPATH' ) || exit; // Protect against direct file access.

use Blocky\Core\Blocks\Renderer\BlockRendererInterface;
use Blocky\Core\Blocks\Node;
use Blocky\Core\Support\HtmlString;
use Blocky\Core\Support\RenderContext;

/**
* Links to product categories with product counts, as plain static markup.
*/
final class ProductCategoriesRenderer implements BlockRendererInterface {

public function render( Node $node, RenderContext $ctx ): HtmlString {
if ( ! taxonomy_exists( 'product_cat' ) ) {
return HtmlString::element(
'div',
$ctx->blockAttrs( $node, array( 'class' => 'text-sm text-text-muted' ) ),
\esc_html__( 'WooCommerce is not active on this site.', 'blocky' )
);
}

$terms = \get_terms( array(
'taxonomy' => 'product_cat',
'hide_empty' => true,
'number' => max( 1, min( 30, (int) ( $node->props['number'] ?? 8 ) ) ),
) );
if ( ! is_array( $terms ) || array() === $terms ) {
return HtmlString::element(
'div',
$ctx->blockAttrs( $node, array( 'class' => 'text-sm text-text-muted' ) ),
\esc_html__( 'No product categories yet.', 'blocky' )
);
}

$items = '';
foreach ( $terms as $term ) {
$link = \get_term_link( $term );
if ( \is_wp_error( $link ) ) {
continue;
}
$items .= '<li><a class="inline-flex items-center gap-2 rounded-button border border-border-subtle px-3 py-1.5 text-sm hover:bg-surface-hover" href="'
. esc_url( $link ) . '">' . esc_html( $term->name )
. '<span class="text-xs text-text-muted">' . (int) $term->count . '</span></a></li>';
}

return HtmlString::element(
'div',
$ctx->blockAttrs( $node, array( 'class' => '' ) ),
'<ul class="flex flex-wrap gap-2">' . $items . '</ul>'
);
}
}
Loading
Loading