You've already forked wc-bootstrap
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8394722cc | |||
| f64e9cd5a2 | |||
| 1e7f82615b | |||
| 9860a184cd | |||
| 5e4af247fa | |||
| 4031a1c8aa |
@@ -24,10 +24,31 @@ jobs:
|
||||
run: |
|
||||
find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 php -l
|
||||
|
||||
test:
|
||||
name: PHPUnit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: mbstring, xml, dom
|
||||
tools: composer:v2
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --no-interaction
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: composer exec -- phpunit
|
||||
|
||||
build-release:
|
||||
name: Build Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint]
|
||||
needs: [test]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -88,8 +109,8 @@ jobs:
|
||||
cp -a . "${STAGING_DIR}/${THEME_NAME}"
|
||||
|
||||
cd "${STAGING_DIR}/${THEME_NAME}"
|
||||
rm -rf .git .gitea .github .vscode .claude releases node_modules
|
||||
rm -f CLAUDE.md PLAN.md composer.json composer.lock .gitignore .editorconfig
|
||||
rm -rf .git .gitea .github .vscode .claude releases node_modules tests .phpunit.cache
|
||||
rm -f CLAUDE.md PLAN.md composer.json composer.lock .gitignore .editorconfig phpunit.xml.dist
|
||||
find . -name '*.log' -o -name '*.po~' -o -name '*.bak' -o -name '.DS_Store' | xargs rm -f 2>/dev/null || true
|
||||
|
||||
cd "${STAGING_DIR}"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,3 +35,4 @@ releases/
|
||||
# Docker runtime
|
||||
.env
|
||||
KNOWN_BUGS.md
|
||||
.phpunit.cache/
|
||||
|
||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -2,6 +2,50 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.1.10] - 2026-03-29
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Add-to-cart button alignment in product cards** (`content-product.html.twig`): Added `mt-auto` to card-footer so the button is always pinned to the bottom of the card, even when the product has no price (e.g., composable products)
|
||||
|
||||
## [0.1.9] - 2026-03-29
|
||||
|
||||
### Changed
|
||||
|
||||
- **Category tree full-width items** (`category-tree-node.html.twig`): Made `<a>` elements direct children of the `list-group` so they span the full parent/offcanvas width; moved chevron toggle inside the link as a `<span>` with click interception
|
||||
- **Category tree nav wrapper** (`category-tree.html.twig`): Added `w-100` to nav and list-group containers
|
||||
|
||||
## [0.1.8] - 2026-03-29
|
||||
|
||||
### Changed
|
||||
|
||||
- **Category tree restyled** (`category-tree.html.twig`, `category-tree-node.html.twig`): Switched from custom link classes to Bootstrap's native `list-group-item list-group-item-action` pattern with nested collapse divs for a more idiomatic Bootstrap look
|
||||
- **Subtler active state** (`wc-bootstrap.css`): Replaced bold primary-colored background with light background (`var(--bs-tertiary-bg)`), left accent border (`3px solid var(--bs-primary)`), and semibold text for a less prominent highlight
|
||||
|
||||
## [0.1.7] - 2026-03-29
|
||||
|
||||
### Added
|
||||
|
||||
- **Product category tree sidebar** on archive/shop and single product pages: hierarchical navigation with collapsible sub-levels up to 3 levels deep, using Bootstrap 5 list-group and collapse components
|
||||
- **`wc_bootstrap_get_category_tree()`** (`functions.php`): Builds nested category array with `is_active`/`is_ancestor` flags for current page context, parent-indexed lookup for O(n) tree construction
|
||||
- **Category tree Twig templates** (`global/category-tree.html.twig`, `global/category-tree-node.html.twig`): Recursive rendering with "All products" link, product count badges, chevron toggle buttons, and `aria-current="page"` accessibility
|
||||
- **Single product sidebar** (`woocommerce/single-product.php`): Added responsive sidebar layout (`col-lg-3` + `col-lg-9`) with offcanvas on mobile, matching the archive page pattern
|
||||
- **Category tree CSS** (`wc-bootstrap.css`): Hover states, active highlighting (primary color), chevron rotation animation, nested indentation, dark mode compatible via Bootstrap CSS variables
|
||||
|
||||
### Changed
|
||||
|
||||
- **Archive sidebar always visible** (`woocommerce/archive-product.php`): Sidebar now renders when categories exist, not only when widgets are configured. Category tree renders above any widgets.
|
||||
|
||||
## [0.1.6] - 2026-03-01
|
||||
|
||||
### Added
|
||||
|
||||
- **PHPUnit test suite** with Brain\Monkey for WordPress function mocking (27 tests, 54 assertions)
|
||||
- **TemplateOverrideTest**: Tests for hook registration, Twig template resolution, output buffer stack (including nesting), context passing, global `$product` injection, and exception fallback
|
||||
- **WooCommerceExtensionTest**: Tests for function/filter registration, `callFunction()` whitelist enforcement, `doAction`/`applyFilters`/`callUserFunc` output capture, `setupProductData`, and all output-capture wrappers
|
||||
- **CI/CD test job**: PHPUnit runs between lint and build-release in Gitea pipeline; test artifacts excluded from release packages
|
||||
- Class stubs for `WPBootstrap\Twig\TwigService` and `WC_Product` enabling isolated unit testing without WordPress/WooCommerce
|
||||
|
||||
## [0.1.5] - 2026-03-01
|
||||
|
||||
### Fixed
|
||||
|
||||
85
CLAUDE.md
85
CLAUDE.md
@@ -345,10 +345,93 @@ The child theme inherits from `wp-bootstrap` via WordPress `Template: wp-bootstr
|
||||
|
||||
## Version History
|
||||
|
||||
Current version: **v0.1.5**
|
||||
Current version: **v0.1.10**
|
||||
|
||||
## Session History
|
||||
|
||||
### 2026-03-29 — v0.1.10 Fix Add-to-Cart Button Alignment
|
||||
|
||||
**Scope:** Fixed product card button jumping when price is missing (e.g., composable products).
|
||||
|
||||
**Files modified (2):**
|
||||
|
||||
- `templates/content-product.html.twig` — Added `mt-auto` to card-footer to pin button to card bottom
|
||||
- `style.css` — Version bump 0.1.9 → 0.1.10
|
||||
|
||||
### 2026-03-29 — v0.1.9 Category Tree Full-Width Items
|
||||
|
||||
**Scope:** Made category tree items span the full parent/offcanvas width.
|
||||
|
||||
**Files modified (3):**
|
||||
|
||||
- `templates/global/category-tree.html.twig` — Added `w-100` to nav and list-group containers
|
||||
- `templates/global/category-tree-node.html.twig` — Made `<a>` elements direct children of the list-group; moved chevron toggle inside the link as a `<span>` with `event.preventDefault()` click interception
|
||||
- `style.css` — Version bump 0.1.8 → 0.1.9
|
||||
|
||||
### 2026-03-29 — v0.1.8 Restyle Category Tree to Idiomatic Bootstrap
|
||||
|
||||
**Scope:** Restyled the category tree sidebar to use Bootstrap's native `list-group-item-action` pattern with subtler active highlighting.
|
||||
|
||||
**Files modified (4):**
|
||||
|
||||
- `templates/global/category-tree.html.twig` — Switched to `list-group-item list-group-item-action` links in a `list-group-flush` container
|
||||
- `templates/global/category-tree-node.html.twig` — Replaced nested `<ul>` lists with `<div>` wrappers and Bootstrap `collapse` divs; level indentation via inline `padding-left`
|
||||
- `assets/css/wc-bootstrap.css` — Replaced primary-colored active background with subtle tertiary background, left accent border, and semibold text; removed unused `.category-tree-link` rules
|
||||
- `style.css` — Version bump 0.1.7 → 0.1.8
|
||||
|
||||
### 2026-03-29 — v0.1.7 Add Product Category Tree Sidebar
|
||||
|
||||
**Scope:** Added a hierarchical product category tree sidebar to archive/shop and single product pages, with collapsible sub-levels up to 3 levels deep.
|
||||
|
||||
**Files created (2):**
|
||||
|
||||
- `templates/global/category-tree.html.twig` — Main category tree template with "All products" link and Bootstrap list-group wrapper
|
||||
- `templates/global/category-tree-node.html.twig` — Recursive node template with collapsible children, count badges, chevron toggle buttons
|
||||
|
||||
**Files modified (5):**
|
||||
|
||||
- `functions.php` — Added `wc_bootstrap_get_category_tree()`: builds hierarchical category array using parent-indexed lookup map, with `is_active`/`is_ancestor` flags based on current query; supports up to 3 nesting levels
|
||||
- `woocommerce/archive-product.php` — Sidebar now renders when categories exist (not just when widgets are configured); category tree rendered above widgets via TwigService
|
||||
- `woocommerce/single-product.php` — Added responsive sidebar layout (`col-lg-3` + `col-lg-9`) with offcanvas on mobile, matching archive page pattern; wraps product content in grid
|
||||
- `assets/css/wc-bootstrap.css` — Category tree styles: hover states, active highlighting (primary color), chevron rotation animation, nested list indentation, dark mode compatible via Bootstrap CSS variables
|
||||
- `style.css` — Version bump 0.1.6 → 0.1.7
|
||||
|
||||
**Key decisions:**
|
||||
|
||||
- **Programmatic tree over widget:** Category tree renders from `get_terms()` data, not dependent on widgets being configured in WP admin. Widgets still render below the tree if configured.
|
||||
- **Recursive Twig `{% include %}` for nesting:** `category-tree-node.html.twig` includes itself for child categories, keeping the template DRY and handling arbitrary depth (capped at 3 by PHP).
|
||||
- **`is_ancestor` auto-expand:** When viewing a subcategory, all ancestor categories auto-expand their collapse panels (`show` class + `aria-expanded="true"`), so the user sees the full path.
|
||||
- **Shared sidebar pattern:** Both archive and single product pages use the same offcanvas-lg responsive pattern — full sidebar on desktop, slide-out panel on mobile.
|
||||
|
||||
### 2026-03-01 — v0.1.6 Add PHPUnit Test Suite
|
||||
|
||||
**Scope:** Added PHPUnit test infrastructure with Brain\Monkey for WordPress function mocking, covering both PHP classes (`TemplateOverride`, `WooCommerceExtension`). Added test job to Gitea CI pipeline.
|
||||
|
||||
**Files created (7):**
|
||||
|
||||
- `phpunit.xml.dist` — PHPUnit 11 configuration (bootstrap, test suite, source coverage)
|
||||
- `tests/bootstrap.php` — Loads Composer autoloader and class stubs
|
||||
- `tests/Stubs/TwigService.php` — `WPBootstrap\Twig\TwigService` stub with injectable render callback and singleton reset
|
||||
- `tests/Stubs/WcProduct.php` — Minimal `\WC_Product` stub with `get_id()`
|
||||
- `tests/Unit/TemplateOverrideTest.php` — 9 tests: hook registration, Twig resolution, buffer stack (including nesting), context passing, global `$product` injection, exception fallback
|
||||
- `tests/Unit/Twig/WooCommerceExtensionTest.php` — 18 tests: function/filter registration, `callFunction()` whitelist enforcement, output capture wrappers, `setupProductData`
|
||||
|
||||
**Files modified (4):**
|
||||
|
||||
- `composer.json` — Added `require-dev` (phpunit ^11.0, brain/monkey ^2.6) and `autoload-dev` PSR-4 mapping
|
||||
- `.gitea/workflows/release.yml` — Added `test` job between `lint` and `build-release`; excluded `tests/`, `phpunit.xml.dist`, `.phpunit.cache` from release packages
|
||||
- `.gitignore` — Changed `.phpunit.cache/test-results` to `.phpunit.cache/`
|
||||
- `style.css` — Version bump 0.1.5 → 0.1.6
|
||||
|
||||
**Key decisions:**
|
||||
|
||||
- **Brain\Monkey over WP_Mock:** Lighter weight, better patchwork integration, same pattern as wp-bootstrap parent theme
|
||||
- **TwigService stub with injectable callback:** Allows tests to control render output without full Twig environment; includes `reset()` for test isolation
|
||||
- **`@` error suppression for `error_log` in fallback test:** PHP internal functions can't be mocked by Brain\Monkey without patchwork.json config; suppressing is simpler than adding patchwork config for a single test
|
||||
- **No patchwork.json:** Avoids complexity; only `error_log` needed mocking and the `@` operator suffices for the test assertion
|
||||
|
||||
**Test results:** 27 tests, 54 assertions, all passing
|
||||
|
||||
### 2026-03-01 — v0.1.5 Fix 10 Known Bugs
|
||||
|
||||
**Scope:** Fixed all 10 bugs from KNOWN_BUGS.md — catalog page features (title, breadcrumbs, categories, filters, sort dropdown), single product fixes (variable form, gallery, related products, grouped products), and downloads page.
|
||||
|
||||
12
README.md
12
README.md
@@ -28,8 +28,9 @@ The bridge hooks into WooCommerce's `woocommerce_before_template_part` and `wooc
|
||||
|
||||
- **99 Twig template overrides** covering all customer-facing WooCommerce pages
|
||||
- **Rendering bridge** (`WooCommerceExtension` + `TemplateOverride`) that intercepts WooCommerce's PHP template pipeline
|
||||
- **~50 Twig functions** and **7 Twig filters** exposing WooCommerce and WordPress APIs to templates
|
||||
- **~50 Twig functions** and **8 Twig filters** exposing WooCommerce and WordPress APIs to templates
|
||||
- Bootstrap 5 responsive markup with dark mode support
|
||||
- **PHPUnit test suite** with Brain\Monkey for isolated unit testing (no WordPress/WooCommerce required)
|
||||
- HPOS compatible (uses `WC_Order` methods only, no `$post` global)
|
||||
- 8 reusable Twig components (card, pagination, price, rating, address-card, status-badge, quantity-input, form-field)
|
||||
- Translation-ready
|
||||
@@ -83,10 +84,15 @@ wc-bootstrap/
|
||||
│ ├── notices/
|
||||
│ ├── order/
|
||||
│ └── single-product/
|
||||
├── tests/
|
||||
│ ├── bootstrap.php # PHPUnit bootstrap (autoloader + stubs)
|
||||
│ ├── Stubs/ # WordPress/WooCommerce class stubs
|
||||
│ └── Unit/ # Unit tests (TemplateOverride, WooCommerceExtension)
|
||||
├── docker/
|
||||
│ ├── Dockerfile # Multistage build (WC download, npm, composer, WP)
|
||||
│ ├── entrypoint.sh # Auto-setup wrapper entrypoint
|
||||
│ └── setup.sh # First-run WP install + plugin/theme activation
|
||||
├── phpunit.xml.dist # PHPUnit 11 configuration
|
||||
├── .env-dist # Environment variable template
|
||||
├── compose.yaml # WordPress + MariaDB services
|
||||
├── compose.override.yaml # Dev overrides (bind mounts, debug flags)
|
||||
@@ -143,8 +149,8 @@ for po in languages/wc-bootstrap-*.po; do msgfmt -o "${po%.po}.mo" "$po"; done
|
||||
Releases are automated via Gitea Actions. Push a tag matching `vX.X.X` to trigger a release build.
|
||||
|
||||
```bash
|
||||
git tag -a v0.1.5 -m "Version 0.1.5 - Fix 10 known bugs"
|
||||
git push origin v0.1.5
|
||||
git tag -a v0.1.6 -m "Version 0.1.6 - Add PHPUnit test suite"
|
||||
git push origin v0.1.6
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@@ -644,3 +644,39 @@ header.sticky-top.is-stuck {
|
||||
.woocommerce-product-gallery--without-images {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Category Tree Sidebar
|
||||
Hierarchical product category navigation with collapsible sub-levels.
|
||||
Uses Bootstrap list-group-item-action with subtle active styling.
|
||||
========================================================================== */
|
||||
|
||||
.category-tree {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.category-tree .list-group-item {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Subtle active state: fw-semibold + left accent border instead of
|
||||
full primary background, keeping the look understated. */
|
||||
.category-tree .list-group-item.active {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
color: var(--bs-body-color);
|
||||
border-left: 3px solid var(--bs-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.category-tree .list-group-item.active small {
|
||||
color: var(--bs-body-secondary) !important;
|
||||
}
|
||||
|
||||
/* Rotate chevron icon when collapsed/expanded */
|
||||
.category-tree-toggle .bi {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.category-tree-toggle[aria-expanded="false"] .bi {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
@@ -14,11 +14,20 @@
|
||||
"php": ">=8.3",
|
||||
"twig/twig": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"brain/monkey": "^2.6",
|
||||
"phpunit/phpunit": "^11.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"WcBootstrap\\": "inc/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"WcBootstrap\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"sort-packages": true
|
||||
|
||||
2038
composer.lock
generated
2038
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
#syntax=docker/dockerfile:1
|
||||
ARG PHP_VERSION=8.4
|
||||
|
||||
###############################################################################
|
||||
# Stage 1 — Download WooCommerce from WordPress.org
|
||||
###############################################################################
|
||||
@@ -48,7 +51,7 @@ RUN composer install --no-dev --optimize-autoloader --no-interaction
|
||||
###############################################################################
|
||||
# Stage 4 — Final WordPress image
|
||||
###############################################################################
|
||||
FROM wordpress:php8.4 AS wp_runtime
|
||||
FROM wordpress:php${PHP_VERSION} AS wp_runtime
|
||||
|
||||
RUN curl -sSfL -o /usr/local/bin/wp \
|
||||
https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \
|
||||
|
||||
@@ -293,6 +293,91 @@ function wc_bootstrap_loop_columns(): int {
|
||||
}
|
||||
add_filter( 'loop_shop_columns', 'wc_bootstrap_loop_columns' );
|
||||
|
||||
/**
|
||||
* Build a hierarchical product category tree up to a given depth.
|
||||
*
|
||||
* Returns a nested array of category objects with children, suitable for
|
||||
* rendering a multi-level navigation sidebar. Each node contains the
|
||||
* WP_Term object plus a 'children' array and an 'is_active'/'is_ancestor'
|
||||
* flag based on the current query.
|
||||
*
|
||||
* @since 0.1.7
|
||||
*
|
||||
* @param int $max_depth Maximum nesting depth (1 = top-level only, 3 = three levels).
|
||||
* @return array Hierarchical category tree.
|
||||
*/
|
||||
function wc_bootstrap_get_category_tree( int $max_depth = 3 ): array {
|
||||
$terms = get_terms( [
|
||||
'taxonomy' => 'product_cat',
|
||||
'hide_empty' => true,
|
||||
'orderby' => 'name',
|
||||
'order' => 'ASC',
|
||||
] );
|
||||
|
||||
if ( is_wp_error( $terms ) || empty( $terms ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Determine the currently viewed category (if any).
|
||||
$current_term_id = 0;
|
||||
$ancestor_ids = [];
|
||||
|
||||
if ( is_product_category() ) {
|
||||
$queried = get_queried_object();
|
||||
if ( $queried instanceof \WP_Term ) {
|
||||
$current_term_id = $queried->term_id;
|
||||
$ancestor_ids = get_ancestors( $current_term_id, 'product_cat', 'taxonomy' );
|
||||
}
|
||||
} elseif ( is_product() ) {
|
||||
// On single product pages, highlight the first assigned category.
|
||||
global $product;
|
||||
if ( $product ) {
|
||||
$cat_ids = $product->get_category_ids();
|
||||
if ( ! empty( $cat_ids ) ) {
|
||||
$current_term_id = $cat_ids[0];
|
||||
$ancestor_ids = get_ancestors( $current_term_id, 'product_cat', 'taxonomy' );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Index terms by parent for efficient tree building.
|
||||
$by_parent = [];
|
||||
foreach ( $terms as $term ) {
|
||||
$by_parent[ $term->parent ][] = $term;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively build tree nodes from the parent-indexed map.
|
||||
*/
|
||||
$build = function ( int $parent_id, int $depth ) use ( &$build, $by_parent, $max_depth, $current_term_id, $ancestor_ids ): array {
|
||||
if ( $depth > $max_depth || ! isset( $by_parent[ $parent_id ] ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$nodes = [];
|
||||
foreach ( $by_parent[ $parent_id ] as $term ) {
|
||||
$children = $build( $term->term_id, $depth + 1 );
|
||||
$is_active = ( $term->term_id === $current_term_id );
|
||||
$is_ancestor = in_array( $term->term_id, $ancestor_ids, true );
|
||||
|
||||
$nodes[] = [
|
||||
'term_id' => $term->term_id,
|
||||
'name' => $term->name,
|
||||
'slug' => $term->slug,
|
||||
'count' => $term->count,
|
||||
'url' => get_term_link( $term ),
|
||||
'is_active' => $is_active,
|
||||
'is_ancestor' => $is_ancestor,
|
||||
'children' => $children,
|
||||
];
|
||||
}
|
||||
|
||||
return $nodes;
|
||||
};
|
||||
|
||||
return $build( 0, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove WooCommerce's default sidebar hook.
|
||||
*
|
||||
|
||||
21
phpunit.xml.dist
Normal file
21
phpunit.xml.dist
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
failOnRisky="true"
|
||||
failOnWarning="true">
|
||||
|
||||
<testsuites>
|
||||
<testsuite name="unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source>
|
||||
<include>
|
||||
<directory>inc</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
@@ -7,7 +7,7 @@ Description: A Bootstrap 5 child theme for WP Bootstrap that overrides all WooCo
|
||||
Requires at least: 6.7
|
||||
Tested up to: 6.7
|
||||
Requires PHP: 8.3
|
||||
Version: 0.1.5
|
||||
Version: 0.1.10
|
||||
License: GNU General Public License v2 or later
|
||||
License URI: http://www.gnu.org/licenses/gpl-2.0.html
|
||||
Template: wp-bootstrap
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
{{ do_action('woocommerce_after_shop_loop_item_title') }}
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent border-0 pt-0 pb-3 px-3">
|
||||
<div class="card-footer bg-transparent border-0 pt-0 pb-3 px-3 mt-auto">
|
||||
{{ do_action('woocommerce_after_shop_loop_item') }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
44
templates/global/category-tree-node.html.twig
Normal file
44
templates/global/category-tree-node.html.twig
Normal file
@@ -0,0 +1,44 @@
|
||||
{#
|
||||
# Single Category Tree Node (recursive)
|
||||
#
|
||||
# Renders one category as a list-group-item-action link with optional
|
||||
# collapsible nested children. Included recursively.
|
||||
#
|
||||
# Expected context:
|
||||
# node - Category node from wc_bootstrap_get_category_tree()
|
||||
# level - Current nesting depth (1-3)
|
||||
#
|
||||
# @package WcBootstrap
|
||||
# @since 0.1.7
|
||||
#}
|
||||
|
||||
{% set has_children = node.children is not empty %}
|
||||
{% set is_open = node.is_active or node.is_ancestor %}
|
||||
|
||||
<a href="{{ node.url }}"
|
||||
class="list-group-item list-group-item-action border-0 py-1 d-flex align-items-center{% if node.is_active %} active{% endif %}"
|
||||
style="padding-left: {{ 0.75 + (level - 1) * 1 }}rem;"
|
||||
{% if node.is_active %}aria-current="page"{% endif %}>
|
||||
<span class="flex-grow-1">{{ node.name }}</span>
|
||||
<small class="text-body-secondary ms-1">({{ node.count }})</small>
|
||||
{% if has_children %}
|
||||
<span class="category-tree-toggle ms-1"
|
||||
role="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#cat-{{ node.term_id }}"
|
||||
aria-expanded="{{ is_open ? 'true' : 'false' }}"
|
||||
aria-controls="cat-{{ node.term_id }}"
|
||||
aria-label="{{ __('Toggle subcategories', 'wc-bootstrap') }}"
|
||||
onclick="event.preventDefault(); event.stopPropagation();">
|
||||
<i class="bi bi-chevron-down small" aria-hidden="true"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
|
||||
{% if has_children %}
|
||||
<div class="collapse{{ is_open ? ' show' : '' }}" id="cat-{{ node.term_id }}">
|
||||
{% for child in node.children %}
|
||||
{% include 'global/category-tree-node.html.twig' with { node: child, level: level + 1 } only %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
32
templates/global/category-tree.html.twig
Normal file
32
templates/global/category-tree.html.twig
Normal file
@@ -0,0 +1,32 @@
|
||||
{#
|
||||
# Product Category Tree (Bootstrap 5)
|
||||
#
|
||||
# Renders a hierarchical product category navigation using Bootstrap
|
||||
# list-group with nested sub-lists and action links.
|
||||
#
|
||||
# Expected context:
|
||||
# categories - Hierarchical array from wc_bootstrap_get_category_tree()
|
||||
# shop_url - URL to the main shop page
|
||||
# current_cat - Term ID of the currently viewed category (0 = shop page)
|
||||
#
|
||||
# @package WcBootstrap
|
||||
# @since 0.1.7
|
||||
#}
|
||||
|
||||
{% if categories is not empty %}
|
||||
<nav class="category-tree w-100" aria-label="{{ __('Product categories', 'wc-bootstrap') }}">
|
||||
<h3 class="sidebar-heading h6 text-uppercase fw-semibold">
|
||||
{{ __('Categories', 'wc-bootstrap') }}
|
||||
</h3>
|
||||
|
||||
<div class="list-group list-group-flush w-100">
|
||||
<a href="{{ shop_url }}"
|
||||
class="list-group-item list-group-item-action border-0 py-1{% if current_cat == 0 %} active{% endif %}">
|
||||
{{ __('All products', 'wc-bootstrap') }}
|
||||
</a>
|
||||
{% for cat in categories %}
|
||||
{% include 'global/category-tree-node.html.twig' with { node: cat, level: 1 } only %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
53
tests/Stubs/TwigService.php
Normal file
53
tests/Stubs/TwigService.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/**
|
||||
* Stub for WPBootstrap\Twig\TwigService.
|
||||
*
|
||||
* Provides just enough surface for TemplateOverride to resolve
|
||||
* and render templates during unit tests.
|
||||
*/
|
||||
|
||||
namespace WPBootstrap\Twig;
|
||||
|
||||
class TwigService
|
||||
{
|
||||
private static ?self $instance = null;
|
||||
|
||||
/** @var callable|null Render callback set by tests. */
|
||||
private static $renderCallback = null;
|
||||
|
||||
public static function getInstance(): self
|
||||
{
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow tests to override the render behaviour.
|
||||
*/
|
||||
public static function setRenderCallback(?callable $callback): void
|
||||
{
|
||||
self::$renderCallback = $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a template with the given context.
|
||||
*/
|
||||
public function render(string $template, array $context = []): string
|
||||
{
|
||||
if (null !== self::$renderCallback) {
|
||||
return (self::$renderCallback)($template, $context);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset singleton between tests.
|
||||
*/
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$instance = null;
|
||||
self::$renderCallback = null;
|
||||
}
|
||||
}
|
||||
21
tests/Stubs/WcProduct.php
Normal file
21
tests/Stubs/WcProduct.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
/**
|
||||
* Stub for WC_Product.
|
||||
*
|
||||
* Minimal implementation for type-hint satisfaction in unit tests.
|
||||
*/
|
||||
|
||||
class WC_Product
|
||||
{
|
||||
private int $id;
|
||||
|
||||
public function __construct(int $id = 0)
|
||||
{
|
||||
$this->id = $id;
|
||||
}
|
||||
|
||||
public function get_id(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
}
|
||||
190
tests/Unit/TemplateOverrideTest.php
Normal file
190
tests/Unit/TemplateOverrideTest.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WcBootstrap\Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
use WcBootstrap\TemplateOverride;
|
||||
use WPBootstrap\Twig\TwigService;
|
||||
|
||||
class TemplateOverrideTest extends TestCase
|
||||
{
|
||||
private TemplateOverride $override;
|
||||
private string $templatePath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Monkey\setUp();
|
||||
|
||||
TwigService::reset();
|
||||
|
||||
// WC_BOOTSTRAP_PATH must point to the theme root so
|
||||
// resolveTwigTemplate() can locate files under templates/.
|
||||
$this->templatePath = dirname(__DIR__, 2) . '/';
|
||||
if (!defined('WC_BOOTSTRAP_PATH')) {
|
||||
define('WC_BOOTSTRAP_PATH', $this->templatePath);
|
||||
}
|
||||
|
||||
$this->override = new TemplateOverride();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
TwigService::reset();
|
||||
Monkey\tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── register() ──────────────────────────────────────────────
|
||||
|
||||
public function testRegisterAddsHooksWhenTwigServiceExists(): void
|
||||
{
|
||||
$calls = [];
|
||||
Functions\when('add_action')->alias(function () use (&$calls): void {
|
||||
$calls[] = func_get_args();
|
||||
});
|
||||
|
||||
$this->override->register();
|
||||
|
||||
$this->assertCount(2, $calls);
|
||||
$this->assertSame('woocommerce_before_template_part', $calls[0][0]);
|
||||
$this->assertSame('woocommerce_after_template_part', $calls[1][0]);
|
||||
}
|
||||
|
||||
// ── beforeTemplatePart / afterTemplatePart ───────────────────
|
||||
|
||||
public function testBeforeTemplatePartRendersAndBuffersWhenTwigTemplateExists(): void
|
||||
{
|
||||
// Use a real template file that exists in the theme.
|
||||
// cart/cart.php -> cart/cart.html.twig
|
||||
$templateName = 'cart/cart.php';
|
||||
|
||||
TwigService::setRenderCallback(function (string $tpl, array $ctx): string {
|
||||
return '<div>twig-rendered</div>';
|
||||
});
|
||||
|
||||
$this->override->beforeTemplatePart($templateName, '', '', []);
|
||||
|
||||
// Output buffer should be active — the PHP template output is being captured.
|
||||
$level = ob_get_level();
|
||||
$this->assertGreaterThan(0, $level);
|
||||
|
||||
// Now simulate the PHP template echoing something.
|
||||
echo 'php-output-should-be-discarded';
|
||||
|
||||
$this->override->afterTemplatePart($templateName, '', '', []);
|
||||
|
||||
// The Twig output was already echoed before the buffer, so we
|
||||
// just verify the buffer was cleaned (level decreased).
|
||||
$this->assertSame($level - 1, ob_get_level());
|
||||
}
|
||||
|
||||
public function testBeforeTemplatePartSkipsWhenNoTwigTemplate(): void
|
||||
{
|
||||
$levelBefore = ob_get_level();
|
||||
|
||||
// Use a template name that has no Twig override.
|
||||
$this->override->beforeTemplatePart('nonexistent/template.php', '', '', []);
|
||||
|
||||
// No buffer should have been started.
|
||||
$this->assertSame($levelBefore, ob_get_level());
|
||||
}
|
||||
|
||||
public function testAfterTemplatePartIgnoresUnbufferedTemplate(): void
|
||||
{
|
||||
$levelBefore = ob_get_level();
|
||||
|
||||
// Calling after without a matching before should be safe.
|
||||
$this->override->afterTemplatePart('cart/cart.php', '', '', []);
|
||||
|
||||
$this->assertSame($levelBefore, ob_get_level());
|
||||
}
|
||||
|
||||
public function testNestedTemplatesHandledCorrectly(): void
|
||||
{
|
||||
// Both templates must exist as Twig files.
|
||||
$outer = 'cart/cart.php';
|
||||
$inner = 'cart/cart-empty.php';
|
||||
|
||||
TwigService::setRenderCallback(fn() => '<div>rendered</div>');
|
||||
|
||||
$levelBefore = ob_get_level();
|
||||
|
||||
$this->override->beforeTemplatePart($outer, '', '', []);
|
||||
$outerLevel = ob_get_level();
|
||||
|
||||
$this->override->beforeTemplatePart($inner, '', '', []);
|
||||
$innerLevel = ob_get_level();
|
||||
|
||||
$this->assertSame($outerLevel + 1, $innerLevel);
|
||||
|
||||
// Close inner first (stack order).
|
||||
$this->override->afterTemplatePart($inner, '', '', []);
|
||||
$this->assertSame($outerLevel, ob_get_level());
|
||||
|
||||
$this->override->afterTemplatePart($outer, '', '', []);
|
||||
$this->assertSame($levelBefore, ob_get_level());
|
||||
}
|
||||
|
||||
public function testBeforeTemplatePartPassesArgsAndProductToTwig(): void
|
||||
{
|
||||
$templateName = 'cart/cart.php';
|
||||
$captured = null;
|
||||
|
||||
TwigService::setRenderCallback(function (string $tpl, array $ctx) use (&$captured): string {
|
||||
$captured = $ctx;
|
||||
return '';
|
||||
});
|
||||
|
||||
$args = ['foo' => 'bar'];
|
||||
$this->override->beforeTemplatePart($templateName, '', '', $args);
|
||||
|
||||
// Clean up the buffer.
|
||||
$this->override->afterTemplatePart($templateName, '', '', $args);
|
||||
|
||||
$this->assertIsArray($captured);
|
||||
$this->assertSame('bar', $captured['foo']);
|
||||
}
|
||||
|
||||
public function testBeforeTemplatePartInjectsGlobalProduct(): void
|
||||
{
|
||||
$templateName = 'cart/cart.php';
|
||||
$captured = null;
|
||||
|
||||
$product = new \WC_Product(42);
|
||||
$GLOBALS['product'] = $product;
|
||||
|
||||
TwigService::setRenderCallback(function (string $tpl, array $ctx) use (&$captured): string {
|
||||
$captured = $ctx;
|
||||
return '';
|
||||
});
|
||||
|
||||
$this->override->beforeTemplatePart($templateName, '', '', []);
|
||||
$this->override->afterTemplatePart($templateName, '', '', []);
|
||||
|
||||
$this->assertSame($product, $captured['product']);
|
||||
|
||||
unset($GLOBALS['product']);
|
||||
}
|
||||
|
||||
public function testBeforeTemplatePartFallsBackOnRenderException(): void
|
||||
{
|
||||
$templateName = 'cart/cart.php';
|
||||
|
||||
TwigService::setRenderCallback(function (): string {
|
||||
throw new \RuntimeException('Twig error');
|
||||
});
|
||||
|
||||
$levelBefore = ob_get_level();
|
||||
|
||||
// Should not throw — falls back silently and lets PHP render.
|
||||
@$this->override->beforeTemplatePart($templateName, '', '', []);
|
||||
|
||||
// No buffer started on failure.
|
||||
$this->assertSame($levelBefore, ob_get_level());
|
||||
}
|
||||
}
|
||||
259
tests/Unit/Twig/WooCommerceExtensionTest.php
Normal file
259
tests/Unit/Twig/WooCommerceExtensionTest.php
Normal file
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WcBootstrap\Tests\Unit\Twig;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
use Twig\TwigFunction;
|
||||
use Twig\TwigFilter;
|
||||
use WcBootstrap\Twig\WooCommerceExtension;
|
||||
|
||||
class WooCommerceExtensionTest extends TestCase
|
||||
{
|
||||
private WooCommerceExtension $extension;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Monkey\setUp();
|
||||
|
||||
$this->extension = new WooCommerceExtension();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Monkey\tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── getFunctions / getFilters ───────────────────────────────
|
||||
|
||||
public function testGetFunctionsReturnsNonEmptyArray(): void
|
||||
{
|
||||
$functions = $this->extension->getFunctions();
|
||||
|
||||
$this->assertNotEmpty($functions);
|
||||
$this->assertContainsOnlyInstancesOf(TwigFunction::class, $functions);
|
||||
}
|
||||
|
||||
public function testGetFiltersReturnsNonEmptyArray(): void
|
||||
{
|
||||
$filters = $this->extension->getFilters();
|
||||
|
||||
$this->assertNotEmpty($filters);
|
||||
$this->assertContainsOnlyInstancesOf(TwigFilter::class, $filters);
|
||||
}
|
||||
|
||||
public function testGetFunctionsContainsExpectedNames(): void
|
||||
{
|
||||
$names = array_map(
|
||||
fn(TwigFunction $f) => $f->getName(),
|
||||
$this->extension->getFunctions()
|
||||
);
|
||||
|
||||
$expected = [
|
||||
'do_action', 'apply_filters', 'wp_nonce_field', 'fn',
|
||||
'wc_get_cart_url', 'wc_get_checkout_url', 'wc_get_template',
|
||||
'wc_print_notices', 'woocommerce_form_field',
|
||||
];
|
||||
|
||||
foreach ($expected as $name) {
|
||||
$this->assertContains($name, $names, "Missing Twig function: {$name}");
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetFiltersContainsExpectedNames(): void
|
||||
{
|
||||
$names = array_map(
|
||||
fn(TwigFilter $f) => $f->getName(),
|
||||
$this->extension->getFilters()
|
||||
);
|
||||
|
||||
$expected = [
|
||||
'esc_html', 'esc_attr', 'esc_url',
|
||||
'sanitize_title', 'wpautop', 'wp_kses_post',
|
||||
'wptexturize', 'do_shortcode',
|
||||
];
|
||||
|
||||
foreach ($expected as $name) {
|
||||
$this->assertContains($name, $names, "Missing Twig filter: {$name}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── callFunction() whitelist ────────────────────────────────
|
||||
|
||||
public function testCallFunctionWithAllowedFunctionSucceeds(): void
|
||||
{
|
||||
// Define a stub for an allowed function.
|
||||
Functions\when('_n')->alias(fn($single, $plural, $count) => $count === 1 ? $single : $plural);
|
||||
|
||||
$result = $this->extension->callFunction('_n', 'item', 'items', 1);
|
||||
|
||||
$this->assertSame('item', $result);
|
||||
}
|
||||
|
||||
public function testCallFunctionWithDisallowedFunctionThrows(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('is not allowed');
|
||||
|
||||
$this->extension->callFunction('exec', 'whoami');
|
||||
}
|
||||
|
||||
public function testCallFunctionWithNonExistentAllowedFunctionThrows(): void
|
||||
{
|
||||
// 'WC' is allowed but won't exist in the test environment.
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('does not exist');
|
||||
|
||||
$this->extension->callFunction('WC');
|
||||
}
|
||||
|
||||
// ── doAction() ──────────────────────────────────────────────
|
||||
|
||||
public function testDoActionCapturesOutput(): void
|
||||
{
|
||||
Functions\when('do_action')->alias(function (string $tag): void {
|
||||
echo "<div>{$tag}</div>";
|
||||
});
|
||||
|
||||
$result = $this->extension->doAction('woocommerce_before_cart');
|
||||
|
||||
$this->assertSame('<div>woocommerce_before_cart</div>', $result);
|
||||
}
|
||||
|
||||
public function testDoActionReturnsEmptyWhenNoOutput(): void
|
||||
{
|
||||
Functions\when('do_action')->justReturn(null);
|
||||
|
||||
$result = $this->extension->doAction('some_silent_hook');
|
||||
|
||||
$this->assertSame('', $result);
|
||||
}
|
||||
|
||||
// ── applyFilters() ──────────────────────────────────────────
|
||||
|
||||
public function testApplyFiltersDelegatesToWordPress(): void
|
||||
{
|
||||
Functions\when('apply_filters')->alias(function (string $tag, ...$args) {
|
||||
return strtoupper($args[0]);
|
||||
});
|
||||
|
||||
$result = $this->extension->applyFilters('the_title', 'hello');
|
||||
|
||||
$this->assertSame('HELLO', $result);
|
||||
}
|
||||
|
||||
// ── callUserFunc() ──────────────────────────────────────────
|
||||
|
||||
public function testCallUserFuncCapturesCallbackOutput(): void
|
||||
{
|
||||
$result = $this->extension->callUserFunc(function (): void {
|
||||
echo 'tab-content';
|
||||
});
|
||||
|
||||
$this->assertSame('tab-content', $result);
|
||||
}
|
||||
|
||||
public function testCallUserFuncPassesArguments(): void
|
||||
{
|
||||
$result = $this->extension->callUserFunc(function (string $key): void {
|
||||
echo "key={$key}";
|
||||
}, 'description');
|
||||
|
||||
$this->assertSame('key=description', $result);
|
||||
}
|
||||
|
||||
// ── setupProductData() ──────────────────────────────────────
|
||||
|
||||
public function testSetupProductDataSetsGlobalProduct(): void
|
||||
{
|
||||
$product = new \WC_Product(99);
|
||||
|
||||
Functions\when('get_post')->justReturn((object) ['ID' => 99]);
|
||||
Functions\when('setup_postdata')->justReturn(true);
|
||||
|
||||
$result = $this->extension->setupProductData($product);
|
||||
|
||||
$this->assertSame('', $result);
|
||||
$this->assertSame($product, $GLOBALS['product']);
|
||||
|
||||
unset($GLOBALS['product'], $GLOBALS['post']);
|
||||
}
|
||||
|
||||
public function testSetupProductDataSkipsPostdataWhenPostNotFound(): void
|
||||
{
|
||||
$product = new \WC_Product(1);
|
||||
|
||||
Functions\when('get_post')->justReturn(null);
|
||||
|
||||
$result = $this->extension->setupProductData($product);
|
||||
|
||||
$this->assertSame('', $result);
|
||||
$this->assertSame($product, $GLOBALS['product']);
|
||||
|
||||
unset($GLOBALS['product']);
|
||||
}
|
||||
|
||||
// ── Output-capture wrappers ─────────────────────────────────
|
||||
|
||||
public function testWcPrintNoticesCapturesOutput(): void
|
||||
{
|
||||
Functions\when('wc_print_notices')->alias(function (): void {
|
||||
echo '<div class="woocommerce-message">Notice</div>';
|
||||
});
|
||||
|
||||
$result = $this->extension->wcPrintNotices();
|
||||
|
||||
$this->assertStringContainsString('woocommerce-message', $result);
|
||||
}
|
||||
|
||||
public function testWcDisplayItemMetaReturnsString(): void
|
||||
{
|
||||
$item = new \stdClass();
|
||||
|
||||
Functions\when('wc_display_item_meta')->alias(function ($item, array $args): string {
|
||||
return $args['echo'] === false ? '<span>meta</span>' : '';
|
||||
});
|
||||
|
||||
$result = $this->extension->wcDisplayItemMeta($item, []);
|
||||
|
||||
$this->assertSame('<span>meta</span>', $result);
|
||||
}
|
||||
|
||||
public function testWcQueryStringFormFieldsReturnsHtml(): void
|
||||
{
|
||||
Functions\when('wc_query_string_form_fields')->alias(function ($values, array $exclude, string $key, bool $return): string {
|
||||
return $return ? '<input type="hidden">' : '';
|
||||
});
|
||||
|
||||
$result = $this->extension->wcQueryStringFormFields(null, [], '');
|
||||
|
||||
$this->assertSame('<input type="hidden">', $result);
|
||||
}
|
||||
|
||||
public function testWoocommerceFormFieldReturnsHtml(): void
|
||||
{
|
||||
Functions\when('woocommerce_form_field')->alias(function (string $key, array $args, $value): string {
|
||||
return $args['return'] === true ? "<p class=\"form-row\">{$key}</p>" : '';
|
||||
});
|
||||
|
||||
$result = $this->extension->woocommerceFormField('billing_first_name', [], null);
|
||||
|
||||
$this->assertStringContainsString('billing_first_name', $result);
|
||||
}
|
||||
|
||||
public function testWcGetTemplateCapturesOutput(): void
|
||||
{
|
||||
Functions\when('wc_get_template')->alias(function (string $name): void {
|
||||
echo "<div>{$name}</div>";
|
||||
});
|
||||
|
||||
$result = $this->extension->wcGetTemplate('order/order-details.php', []);
|
||||
|
||||
$this->assertSame('<div>order/order-details.php</div>', $result);
|
||||
}
|
||||
}
|
||||
14
tests/bootstrap.php
Normal file
14
tests/bootstrap.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
/**
|
||||
* PHPUnit bootstrap file.
|
||||
*
|
||||
* Loads Composer autoloader and class stubs required
|
||||
* for unit testing outside of WordPress/WooCommerce.
|
||||
*/
|
||||
|
||||
// Composer autoloader (loads WcBootstrap\* classes + Brain\Monkey).
|
||||
require_once dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
// Class stubs (WordPress / WooCommerce / parent theme).
|
||||
require_once __DIR__ . '/Stubs/TwigService.php';
|
||||
require_once __DIR__ . '/Stubs/WcProduct.php';
|
||||
@@ -25,7 +25,9 @@ woocommerce_breadcrumb();
|
||||
// Fire structured data hook (normally on woocommerce_before_main_content at priority 30).
|
||||
do_action( 'woocommerce_shop_loop_header' );
|
||||
|
||||
$has_sidebar = is_active_sidebar( 'shop-sidebar' );
|
||||
$has_widgets = is_active_sidebar( 'shop-sidebar' );
|
||||
$category_tree = wc_bootstrap_get_category_tree();
|
||||
$has_sidebar = $has_widgets || ! empty( $category_tree );
|
||||
|
||||
if ( woocommerce_product_loop() ) {
|
||||
|
||||
@@ -62,7 +64,23 @@ if ( woocommerce_product_loop() ) {
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="<?php esc_attr_e( 'Close', 'wc-bootstrap' ); ?>"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body p-0">
|
||||
<?php dynamic_sidebar( 'shop-sidebar' ); ?>
|
||||
<?php
|
||||
// Render the category tree.
|
||||
if ( ! empty( $category_tree ) ) {
|
||||
$twig = \WPBootstrap\Twig\TwigService::getInstance();
|
||||
$current_cat = is_product_category() ? get_queried_object_id() : 0;
|
||||
echo $twig->render( 'global/category-tree.html.twig', [
|
||||
'categories' => $category_tree,
|
||||
'shop_url' => wc_get_page_permalink( 'shop' ),
|
||||
'current_cat' => $current_cat,
|
||||
] );
|
||||
}
|
||||
|
||||
// Render any configured widgets.
|
||||
if ( $has_widgets ) {
|
||||
dynamic_sidebar( 'shop-sidebar' );
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -22,10 +22,66 @@ defined( 'ABSPATH' ) || exit;
|
||||
*/
|
||||
do_action( 'woocommerce_before_main_content' );
|
||||
|
||||
while ( have_posts() ) {
|
||||
the_post();
|
||||
wc_get_template_part( 'content', 'single-product' );
|
||||
}
|
||||
$category_tree = wc_bootstrap_get_category_tree();
|
||||
$has_widgets = is_active_sidebar( 'shop-sidebar' );
|
||||
$has_sidebar = ! empty( $category_tree ) || $has_widgets;
|
||||
|
||||
?>
|
||||
<div class="row g-4">
|
||||
<?php if ( $has_sidebar ) : ?>
|
||||
<aside class="col-lg-3 mb-4 mb-lg-0">
|
||||
<button class="btn btn-outline-secondary w-100 d-lg-none mb-3"
|
||||
type="button"
|
||||
data-bs-toggle="offcanvas"
|
||||
data-bs-target="#shopSidebar"
|
||||
aria-controls="shopSidebar">
|
||||
<i class="bi bi-list me-1" aria-hidden="true"></i>
|
||||
<?php esc_html_e( 'Categories', 'wc-bootstrap' ); ?>
|
||||
</button>
|
||||
|
||||
<div class="offcanvas-lg offcanvas-start"
|
||||
id="shopSidebar"
|
||||
tabindex="-1"
|
||||
aria-labelledby="shopSidebarLabel">
|
||||
<div class="offcanvas-header d-lg-none">
|
||||
<h5 class="offcanvas-title" id="shopSidebarLabel">
|
||||
<?php esc_html_e( 'Categories', 'wc-bootstrap' ); ?>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="<?php esc_attr_e( 'Close', 'wc-bootstrap' ); ?>"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body p-0">
|
||||
<?php
|
||||
if ( ! empty( $category_tree ) ) {
|
||||
$twig = \WPBootstrap\Twig\TwigService::getInstance();
|
||||
echo $twig->render( 'global/category-tree.html.twig', [
|
||||
'categories' => $category_tree,
|
||||
'shop_url' => wc_get_page_permalink( 'shop' ),
|
||||
'current_cat' => 0,
|
||||
] );
|
||||
}
|
||||
|
||||
if ( $has_widgets ) {
|
||||
dynamic_sidebar( 'shop-sidebar' );
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="col-lg-9">
|
||||
<?php else : ?>
|
||||
<div class="col-12">
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
while ( have_posts() ) {
|
||||
the_post();
|
||||
wc_get_template_part( 'content', 'single-product' );
|
||||
}
|
||||
?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Hook: woocommerce_after_main_content.
|
||||
|
||||
Reference in New Issue
Block a user