Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
d9394c8
start Shopping list skeleton
adriendupuis Dec 3, 2025
4da1c78
start Shopping list feature guide
adriendupuis Dec 4, 2025
674bcfb
api_refs.sh: Add shopping-list
adriendupuis Dec 5, 2025
eb16b86
shopping list cards
adriendupuis Dec 8, 2025
08d4f86
enhance cards
adriendupuis Dec 8, 2025
ab19ccd
draft shopping_list_guide.md and install_shopping_list.md
adriendupuis Dec 9, 2025
6fd2514
shopping list APIs and use cases drafts
adriendupuis Dec 18, 2025
16bf9ab
Shopping list events
adriendupuis Dec 18, 2025
3bf4298
Continue API doc skeleton
adriendupuis Dec 19, 2025
fe36e0f
Merge branch '5.0' into shopping-list
adriendupuis Dec 19, 2025
1dd082e
update rest_api_reference.html preview
adriendupuis Jan 8, 2026
10542d7
continue Shopping List APIs
adriendupuis Jan 8, 2026
05b9dd7
shopping_list_api.md: `moveEntries` example
adriendupuis Jan 12, 2026
b19fa53
Merge branch '5.0' into shopping-list
adriendupuis Jan 22, 2026
86f8124
Regenerate rest_api_reference.html
adriendupuis Jan 22, 2026
7228fb6
Regenerate php_api_reference
adriendupuis Jan 22, 2026
b1c108e
Fix #tag/Shopping-List (no plural)
adriendupuis Jan 22, 2026
15a2ef5
Continue role & permission
adriendupuis Jan 22, 2026
093ba65
Continue role & permission
adriendupuis Jan 26, 2026
d41dc22
Continue install_shopping_list.md
adriendupuis Jan 26, 2026
63d1bfb
Continue shopping_list_guide.md
adriendupuis Jan 26, 2026
bc6b1b0
sort shopping list pages' meta
adriendupuis Jan 27, 2026
edf0209
Shopping list templates
adriendupuis Jan 28, 2026
a84fcc1
Shopping list templates
adriendupuis Jan 28, 2026
cf8c65c
install_shopping_list.md: Em. on create permission
adriendupuis Jan 29, 2026
e0f7c8c
Update rest_api_reference.html
adriendupuis Jan 29, 2026
d9666e1
shopping_list_templates.md: Rework table
adriendupuis Feb 4, 2026
4fb06ec
search_api.md: Add ShoppingListAdapter
adriendupuis Feb 4, 2026
89a0b62
Apply suggestions from code review
adriendupuis Feb 4, 2026
d6deae0
Merge branch '5.0' into shopping-list
adriendupuis Feb 4, 2026
bba2f96
policies.md: ShoppingListOwner
adriendupuis Feb 4, 2026
5dfbc6c
shopping_list_criteria.md: meta desc, typo
adriendupuis Feb 4, 2026
fffc6e8
shopping_list_sort_clauses.md: Table with ref links
adriendupuis Feb 4, 2026
eb654b0
shopping_list_templates.md: User menu
adriendupuis Feb 5, 2026
ff8de38
generate rest_api_reference.html
adriendupuis Feb 5, 2026
545950b
Shopping List PHP API Ref
adriendupuis Feb 5, 2026
99e2d18
Continue intro to Shopping List
adriendupuis Feb 6, 2026
edebada
shopping_list_templates.md: ShoppingListViewSubscriber
adriendupuis Feb 6, 2026
291e9f7
Merge branch '5.0' into shopping-list
adriendupuis Feb 6, 2026
a33b54b
Merge branch '5.0' into shopping-list
adriendupuis Feb 6, 2026
794dd19
shopping_list_api.md: Split in sub-sections
adriendupuis Feb 6, 2026
831fa17
shopping_list_templates.md: Rewrite component usage
adriendupuis Feb 10, 2026
bf1daae
shopping_list_templates.md: Rewrite component usage
adriendupuis Feb 10, 2026
7c5afb6
Merge branch '5.0' into shopping-list
adriendupuis Feb 10, 2026
248bc55
Merge branch '5.0' into shopping-list
adriendupuis Feb 10, 2026
6b6a21f
re-generate REST API Ref after merge
adriendupuis Feb 11, 2026
8e2882e
PHP & JS CS Fixes
adriendupuis Feb 11, 2026
dafdc7c
shopping_list_templates.md: fix typo
adriendupuis Feb 11, 2026
b39d9e3
ProductViewController.php: Fix return
adriendupuis Feb 11, 2026
5ded619
Merge remote-tracking branch 'origin/shopping-list' into shopping-list
adriendupuis Feb 11, 2026
57da608
Continue search API
adriendupuis Feb 11, 2026
e05f97d
shopping_list_templates.md: Still trying to format the table
adriendupuis Feb 11, 2026
e98cb33
shopping_list_templates.md → shopping_list_design.md
adriendupuis Feb 11, 2026
59dfd31
Minor adjustments
adriendupuis Feb 11, 2026
2be095b
Minor adjustments
adriendupuis Feb 11, 2026
9e0c4c6
shopping_list_api.md: Keep only one example
adriendupuis Feb 11, 2026
30ca955
Apply suggestions from code review
adriendupuis Feb 11, 2026
0bde436
Apply suggestions from code review
adriendupuis Feb 11, 2026
15eaf25
install_shopping_list.md: Highlight max_lists_per_user VS default
adriendupuis Feb 11, 2026
237335c
install_shopping_list.md: Move migration to code_samples
adriendupuis Feb 11, 2026
b4af677
shopping_list_api.md: Move to code_samples/
adriendupuis Feb 11, 2026
f2238c2
shopping_list_api.md: Move to code_samples/
adriendupuis Feb 11, 2026
cdec608
PHP & JS CS Fixes
adriendupuis Feb 11, 2026
802c4d6
shopping_list_api.md: Fix includes after fixes
adriendupuis Feb 11, 2026
c62dcdf
CartShoppingListTransferCommand.php: add comments
adriendupuis Feb 11, 2026
3618acf
composer.json + ibexa/shopping-list
adriendupuis Feb 12, 2026
3163d60
CartShoppingListTransferCommand → CartShoppingListTransferController
adriendupuis Feb 12, 2026
c239c6f
CartShoppingListTransferCommand → CartShoppingListTransferController
adriendupuis Feb 12, 2026
05fbb13
CartShoppingListTransferCommand → CartShoppingListTransferController
adriendupuis Feb 12, 2026
a64d925
ShoppingListMoveCommand.php CS
adriendupuis Feb 12, 2026
111dee3
CartShoppingListTransferController.php CS
adriendupuis Feb 12, 2026
0cc35ac
CartShoppingListTransferController.php fix argument.type
adriendupuis Feb 12, 2026
9bed077
CartShoppingListTransferController.php fix Contracts usage
adriendupuis Feb 12, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Shopping list service
import ShoppingList from '@ibexa-shopping-list/src/bundle/Resources/public/js/component/shopping.list';
// The Add to shopping list interaction
import { AddToShoppingList } from '@ibexa-shopping-list/src/bundle/Resources/public/js/component/add.to.shopping.list';
// List of all user's shopping lists
import { ShoppingListsList } from '@ibexa-shopping-list/src/bundle/Resources/public/js/component/shopping.lists.list';

(function (global: Window, doc: Document) {
const shoppingList = new ShoppingList();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably we can mention that there should be exactly one instance of ShoppingList, which is accessible on window.ibexaShoppingList after init.
And that it is a wrapper for service functions shopping-list/src/bundle/Resources/public/js/service/shopping.list.ts.
After new shopping list data was loaded with it, it dispatches the ibexa-shopping-list:shopping-lists-data-changed event:

document.body.dispatchEvent(
    new CustomEvent<ShoppingListChangedDetail>('ibexa-shopping-list:shopping-lists-data-changed', {
        detail: { shoppingList: this },
    }),
);

shoppingList.init(); // Fetch user's shopping lists

const addToShoppingListsNodes = doc.querySelectorAll<HTMLDivElement>('.ibexa-sl-add-to-shopping-list');
addToShoppingListsNodes.forEach((addToShoppingListNode) => {
const addToShoppingList = new AddToShoppingList({ node: addToShoppingListNode, ListClass: ShoppingListsList });

addToShoppingList.init();
});
})(window, window.document);
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
ibexa:
system:
default:
content_view:
full:
product:
controller: 'App\Controller\ProductViewController::viewAction'
template: '@ibexadesign/full/product.html.twig'
match:
'@Ibexa\Contracts\ProductCatalog\ViewMatcher\ProductBased\IsProduct': true
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php declare(strict_types=1);

namespace App\Controller;

use Ibexa\Contracts\Core\Repository\Iterator\BatchIterator;
use Ibexa\Contracts\ProductCatalog\Iterator\BatchIteratorAdapter\ProductVariantFetchAdapter;
use Ibexa\Contracts\ProductCatalog\Local\LocalProductServiceInterface;
use Ibexa\Contracts\ProductCatalog\Values\Product\ProductVariantQuery;
use Ibexa\Core\MVC\Symfony\View\ContentView;
use Ibexa\Core\MVC\Symfony\View\View;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;

class ProductViewController extends AbstractController
{
public function __construct(private LocalProductServiceInterface $productService)
{
}

public function viewAction(Request $request, ContentView $view): View

Check failure on line 20 in code_samples/shopping_list/add_to_shopping_list/src/Controller/ProductViewController.php

View workflow job for this annotation

GitHub Actions / Validate code samples (8.3)

App\Controller\ProductViewController must not depend on Ibexa\Core\MVC\Symfony\View\View (CodeSamples on IbexaNotAllowed)

Check failure on line 20 in code_samples/shopping_list/add_to_shopping_list/src/Controller/ProductViewController.php

View workflow job for this annotation

GitHub Actions / Validate code samples (8.3)

App\Controller\ProductViewController must not depend on Ibexa\Core\MVC\Symfony\View\ContentView (CodeSamples on IbexaNotAllowed)
{
$product = $this->productService->getProductFromContent($view->getContent());
if ($product->isBaseProduct()) {
$view->addParameters([
'variants' => new BatchIterator(new ProductVariantFetchAdapter(
$this->productService,
$product,
new ProductVariantQuery(),
)),
]);
}

return $view;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{% extends '@ibexadesign/pagelayout.html.twig' %}

{% set product = content|ibexa_get_product %}

{% block meta %}
{% set token = csrf_token ?? csrf_token(ibexa_get_rest_csrf_token_intention()) %}
<meta name="CSRF-Token" content="{{ token }}"/>
<meta name="SiteAccess" content="{{ app.request.get('siteaccess').name }}"/>
{% endblock %}

{% block content %}
{{ ibexa_content_name(content) }}
{{ product.code }}
{% if not product.isBaseProduct() and can_view_shopping_list and can_edit_shopping_list %}
{% set component %}
{% include '@ibexadesign/shopping_list/component/add_to_shopping_list/add_to_shopping_list.html.twig' with {
product_code: product.code,
} %}
{% endset %}
{{ _self.add_to_shopping_list(product, component) }}
{% endif %}

{% if product.isBaseProduct() %}
<ul>
{% for variant in variants %}
<li>
{{ variant.name }}
{{ variant.code }}
{% if can_view_shopping_list and can_edit_shopping_list %}
{% set component %}
{% include '@ibexadesign/shopping_list/component/add_to_shopping_list/add_to_shopping_list.html.twig' with {
product_code: variant.code,
} %}
{% endset %}
{{ _self.add_to_shopping_list(variant, component) }}
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

{% block javascripts %}
{{ encore_entry_script_tags('add-to-shopping-list-js') }}
{% endblock %}

{% macro add_to_shopping_list(product, component) %}
{% set widget_id = 'add-to-shopping-list-' ~ product.code|slug %}
<button
onclick="(function(){let e=document.getElementById('{{ widget_id }}'); e.style.display=('none'===window.getComputedStyle(e).display)?'block':'none';})()">
Add to shopping list
</button>
<div id="{{ widget_id }}" style="display: none;">
{{ component }}
</div>
{% endmacro %}
60 changes: 60 additions & 0 deletions code_samples/shopping_list/add_to_shopping_list/webpack.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const fs = require('fs');
const path = require('path');
const Encore = require('@symfony/webpack-encore');
const getWebpackConfigs = require('@ibexa/frontend-config/webpack-config/get-configs');
const customConfigsPaths = require('./var/encore/ibexa.webpack.custom.config.js');

const customConfigs = getWebpackConfigs(Encore, customConfigsPaths);
const isReactBlockPathCreated = fs.existsSync('./assets/page-builder/react/blocks');

Encore.reset();
Encore
.setOutputPath('public/build/')
.setPublicPath('/build')
.enableSassLoader()
.enableReactPreset((options) => {
options.runtime = 'classic';
})
.enableSingleRuntimeChunk()
.copyFiles({
from: './assets/images',
to: 'images/[path][name].[ext]',
pattern: /\.(png|svg)$/,
})
.configureBabelPresetEnv((config) => {
config.useBuiltIns = 'usage';
config.corejs = 3;
});

// Welcome page stylesheets
Encore.addEntry('welcome-page-css', [
path.resolve(__dirname, './assets/scss/welcome-page.scss'),
]);

// Welcome page javascripts
Encore.addEntry('welcome-page-js', [
path.resolve(__dirname, './assets/js/welcome.page.js'),
]);

if (isReactBlockPathCreated) {
// React Blocks javascript
Encore.addEntry('react-blocks-js', './assets/js/react.blocks.js');
}

//Encore.addEntry('app', './assets/app.js');

Encore
.enableTypeScriptLoader()
.addAliases({
'@ibexa-shopping-list': path.resolve('./vendor/ibexa/shopping-list'),
})
.addEntry('add-to-shopping-list-js', [
path.resolve(__dirname, './assets/js/add-to-shopping-list.ts'),
])
;

const projectConfig = Encore.getWebpackConfig();

projectConfig.name = 'app';

module.exports = [...customConfigs, projectConfig];
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
- type: reference
mode: load
filename: references/customer_group_references.yml

- type: role
mode: create
metadata:
identifier: Shopping List User
policies:
- module: shopping_list
function: create
limitations:
- identifier: ShoppingListOwner
values: [self]
- module: shopping_list
function: view
limitations:
- identifier: ShoppingListOwner
values: [self]
- module: shopping_list
function: edit
limitations:
- identifier: ShoppingListOwner
values: [self]
- module: shopping_list
function: delete
limitations:
- identifier: ShoppingListOwner
values: [self]
actions:
- action: assign_role_to_user_group
value:
id: 'reference:ref__checkout__customers_user_group__content_id'
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php declare(strict_types=1);

namespace App\shopping_list\php_api\src\Command;

use Ibexa\Contracts\Core\Repository\PermissionResolver;
use Ibexa\Contracts\Core\Repository\UserService;
use Ibexa\Contracts\ShoppingList\ShoppingListServiceInterface;
use Ibexa\Contracts\ShoppingList\Value\EntryAddStruct;
use Ibexa\Contracts\ShoppingList\Value\ShoppingListCreateStruct;
use Ibexa\Contracts\ShoppingList\Value\ShoppingListInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'app:shopping-list:move', description: 'Test move entries between shopping lists')]
class ShoppingListMoveCommand extends Command
{
public function __construct(
private UserService $userService,
private PermissionResolver $permissionResolver,
private ShoppingListServiceInterface $shoppingListService
) {
parent::__construct();
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$login = 'admin';
$productCodes = ['TODO', 'TODO'];
$movedProductCodes = $productCodes;
$prefix = 'shopping-list-test';

$user = $this->userService->loadUserByLogin($login);
$this->permissionResolver->setCurrentUserReference($user);

$sourceList = $this->shoppingListService->createShoppingList(new ShoppingListCreateStruct($prefix . '-source'));
$targetList = $this->shoppingListService->createShoppingList(new ShoppingListCreateStruct($prefix . '-target'));

$sourceList = $this->shoppingListService->addEntries($sourceList, [new EntryAddStruct($productCodes[0]), new EntryAddStruct($productCodes[1])]);
$targetList = $this->shoppingListService->addEntries($targetList, [new EntryAddStruct($productCodes[1])]);

$entriesToMove = [];
$entriesToRemove = [];
foreach ($movedProductCodes as $productCode) {
if ($sourceList->getEntries()->hasEntryWithProductCode($productCode)) {
if ($targetList->getEntries()->hasEntryWithProductCode($productCode)) {
$entriesToRemove[] = $sourceList->getEntries()->getEntryWithProductCode($productCode);
} else {
$entriesToMove[] = $sourceList->getEntries()->getEntryWithProductCode($productCode);
}
}
}
$this->shoppingListService->moveEntries($targetList, $entriesToMove);
$targetList = $this->shoppingListService->getShoppingList($targetList->getIdentifier()); // Refresh local object from persistence
$sourceList = $this->shoppingListService->removeEntries($sourceList, $entriesToRemove); // Refresh local object from persistence even if $entriesToRemove is empty

$this->displayList($output, $sourceList);
$this->displayList($output, $targetList);

$this->shoppingListService->deleteShoppingList($sourceList);
$this->shoppingListService->deleteShoppingList($targetList);

return Command::SUCCESS;
}

private function displayList(OutputInterface $output, ShoppingListInterface $list): void
{
$output->writeln("{$list->getOwner()->getName()} ({$list->getOwner()->getLogin()})");
$output->writeln("{$list->getName()} ({$list->getIdentifier()})" . ($list->isDefault() ? ' [default]' : ''));
$entries = $list->getEntries();
$output->writeln(count($entries) . (count($entries) > 1 ? ' entries' : ' entry'));
foreach ($entries as $entry) {
$output->writeln("- <info>{$entry->getProduct()->getName()} ({$entry->getProduct()->getCode()})</info>");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php declare(strict_types=1);

namespace App\Controller;

use Ibexa\Contracts\Cart\CartServiceInterface;
use Ibexa\Contracts\Cart\CartShoppingListTransferServiceInterface;
use Ibexa\Contracts\Cart\Value\CartCreateStruct;
use Ibexa\Contracts\Cart\Value\CartQuery;
use Ibexa\Contracts\Core\Repository\PermissionResolver;
use Ibexa\Contracts\Core\Repository\UserService;
use Ibexa\Contracts\ProductCatalog\CurrencyServiceInterface;
use Ibexa\Contracts\ProductCatalog\Local\LocalProductServiceInterface;
use Ibexa\Contracts\ShoppingList\ShoppingListServiceInterface;
use Ibexa\Contracts\ShoppingList\Value\EntryAddStruct as ShoppingListEntryAddStruct;
use Ibexa\Contracts\ShoppingList\Value\Query\Criterion\NameCriterion;
use Ibexa\Contracts\ShoppingList\Value\ShoppingListCreateStruct;
use Ibexa\Contracts\ShoppingList\Value\ShoppingListQuery;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class CartShoppingListTransferController extends AbstractController
{
public function __construct(
private UserService $userService,
private PermissionResolver $permissionResolver,
private CurrencyServiceInterface $currencyService,
private CartServiceInterface $cartService,
private ShoppingListServiceInterface $shoppingListService,
private CartShoppingListTransferServiceInterface $cartShoppingListTransferService,
private LocalProductServiceInterface $productService
) {
}

#[Route(
path: '/app-shopping-list-cart',
name: 'app.shopping-list.cart',
methods: ['GET']
)]
public function __invoke(Request $request): Response
{
$currency = 'EUR';
$productCode = 'TODO';

$user = $this->userService->loadUser($this->permissionResolver->getCurrentUserReference()->getUserId());
$name = 'cart-shopping-list-transfer-test';

$cartQuery = new CartQuery();
$cartQuery->setOwnerId($user->getId());
$cartsList = $this->cartService->findCarts($cartQuery);
$cart = null;
foreach ($cartsList->getCarts() as $cart) {
if ($cart->getName() === $name) {
break;
}
$cart = null;
}
if (null === $cart) {
$cart = $this->cartService->createCart(new CartCreateStruct($name, $this->currencyService->getCurrencyByCode($currency), $user));
}

$lists = $this->shoppingListService->findShoppingLists(new ShoppingListQuery(new NameCriterion($name)));
if ($lists->getTotalCount()) {
$list = $lists->getShoppingLists()[0];
} else {
$list = $this->shoppingListService->createShoppingList(new ShoppingListCreateStruct($name));
}

$this->cartService->emptyCart($cart);
$list = $this->shoppingListService->clearShoppingList($list);

$list = $this->shoppingListService->addEntries($list, [new ShoppingListEntryAddStruct($productCode)]);

$cart = $this->cartShoppingListTransferService->addSelectedEntriesToCart($list, [$list->getEntries()->getEntryWithProductCode($productCode)->getIdentifier()], $cart);

dump(
$list->getEntries()->hasEntryWithProductCode($productCode), // true as the entry is copied and not moved
$cart->getEntries()->hasEntryForProduct($this->productService->getProduct($productCode)) // true
);

$list = $this->shoppingListService->clearShoppingList($list); // Empty the list to avoid duplicate and test the move from cart

$list = $this->cartShoppingListTransferService->moveCartToShoppingList($cart, $list);
$cart = $this->cartService->getCart($cart->getIdentifier()); // Refresh local object from persistence

dump(
$list->getEntries()->hasEntryWithProductCode($productCode), // true as, after the clear, the entry is moved from cart
$cart->getEntries()->hasEntryForProduct($this->productService->getProduct($productCode)) // false as the entry was moved
);

return new Response('<html><head></head><body></body></html>');
}
}
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"ibexa/messenger": "~5.0.x-dev",
"ibexa/collaboration": "~5.0.x-dev",
"ibexa/share": "~5.0.x-dev",
"ibexa/shopping-list": "~5.0.x-dev",
"ibexa/phpstan": "~5.0.-dev",
"deptrac/deptrac": "^3.0"
},
Expand Down
Loading
Loading