diff --git a/code_samples/collaboration/config/services.yaml b/code_samples/collaboration/config/services.yaml new file mode 100644 index 0000000000..c6d81568ae --- /dev/null +++ b/code_samples/collaboration/config/services.yaml @@ -0,0 +1,48 @@ +# This file is the entry point to configure your own services. +# Files in the packages/ subdirectory configure your dependencies. + +# Put parameters here that don't need to change on each machine where the app is deployed +# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration +parameters: + +services: + # default configuration for services in *this* file + _defaults: + autowire: true # Automatically injects dependencies in your services. + autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + + # makes classes in src/ available to be used as services + # this creates a service per class whose id is the fully-qualified class name + App\: + resource: '../src/' + + # add more service definitions when explicit configuration is needed + # please note that last definitions always *replace* previous ones + + App\Collaboration\Cart\Persistence\Gateway\DatabaseGateway: + arguments: + $connection: '@ibexa.persistence.connection' + tags: + - name: 'ibexa.collaboration.persistence.session.gateway' + discriminator: !php/const App\Collaboration\Cart\Persistence\Gateway\DatabaseGateway::DISCRIMINATOR + + App\Collaboration\Cart\Persistence\Mapper: + tags: + - name: 'ibexa.collaboration.persistence.session.mapper' + discriminator: !php/const App\Collaboration\Cart\Persistence\Gateway\DatabaseGateway::DISCRIMINATOR + + App\Collaboration\Cart\Mapper\CartSessionDomainMapper: + tags: + - name: 'ibexa.collaboration.service.session.domain.mapper' + type: App\Collaboration\Cart\Persistence\Values\CartSession + + App\Collaboration\Cart\Mapper\CartSessionPersistenceMapper: + tags: + - name: 'ibexa.collaboration.service.session.persistence.mapper' + type: !php/const App\Collaboration\Cart\CartSessionType::IDENTIFIER + + App\Collaboration\Cart\PermissionResolverDecorator: + decorates: Ibexa\Contracts\ProductCatalog\PermissionResolverInterface + + App\Collaboration\Cart\CartResolverDecorator: + decorates: Ibexa\Contracts\Cart\CartResolverInterface diff --git a/code_samples/collaboration/ibexa_collaboration_cart.mysql.sql b/code_samples/collaboration/ibexa_collaboration_cart.mysql.sql new file mode 100644 index 0000000000..74b94b6a62 --- /dev/null +++ b/code_samples/collaboration/ibexa_collaboration_cart.mysql.sql @@ -0,0 +1,7 @@ +CREATE TABLE ibexa_collaboration_cart ( + id INT NOT NULL PRIMARY KEY, + cart_identifier VARCHAR(255) NOT NULL, + CONSTRAINT ibexa_collaboration_cart_ibexa_collaboration_id_fk + FOREIGN KEY (id) REFERENCES ibexa_collaboration (id) + ON DELETE CASCADE +) COLLATE = utf8mb4_general_ci; diff --git a/code_samples/collaboration/ibexa_collaboration_cart.postgresql.sql b/code_samples/collaboration/ibexa_collaboration_cart.postgresql.sql new file mode 100644 index 0000000000..a073132fca --- /dev/null +++ b/code_samples/collaboration/ibexa_collaboration_cart.postgresql.sql @@ -0,0 +1,7 @@ +CREATE TABLE ibexa_collaboration_cart ( + id INTEGER NOT NULL PRIMARY KEY, + cart_identifier VARCHAR(255) NOT NULL, + CONSTRAINT ibexa_collaboration_cart_ibexa_collaboration_id_fk + FOREIGN KEY (id) REFERENCES ibexa_collaboration (id) + ON DELETE CASCADE +); diff --git a/code_samples/collaboration/src/Collaboration/Cart/CartResolverDecorator.php b/code_samples/collaboration/src/Collaboration/Cart/CartResolverDecorator.php new file mode 100644 index 0000000000..9bf359e4a9 --- /dev/null +++ b/code_samples/collaboration/src/Collaboration/Cart/CartResolverDecorator.php @@ -0,0 +1,51 @@ +hasSharedCart()) { + return $this->getSharedCart() ?? $this->innerCartResolver->resolveCart($user); + } + + return $this->innerCartResolver->resolveCart($user); + } + + private function getSharedCart(): ?CartInterface + { + try { + $session = $this->sessionService->getSessionByToken( + $this->requestStack->getSession()->get(PermissionResolverDecorator::COLLABORATION_SESSION_ID) + ); + + if (!$session instanceof CartSession) { + return null; + } + + return $session->getCart(); + } catch (NotFoundException|\Ibexa\ProductCatalog\Exception\UnauthorizedException) { + return null; + } + } + + private function hasSharedCart(): bool + { + return $this->requestStack->getSession()->has(PermissionResolverDecorator::COLLABORATION_SESSION_ID); + } +} diff --git a/code_samples/collaboration/src/Collaboration/Cart/CartSession.php b/code_samples/collaboration/src/Collaboration/Cart/CartSession.php new file mode 100644 index 0000000000..dcacef4130 --- /dev/null +++ b/code_samples/collaboration/src/Collaboration/Cart/CartSession.php @@ -0,0 +1,31 @@ +cart; + } +} diff --git a/code_samples/collaboration/src/Collaboration/Cart/CartSessionCreateStruct.php b/code_samples/collaboration/src/Collaboration/Cart/CartSessionCreateStruct.php new file mode 100644 index 0000000000..9594b61cd7 --- /dev/null +++ b/code_samples/collaboration/src/Collaboration/Cart/CartSessionCreateStruct.php @@ -0,0 +1,29 @@ +cart; + } + + public function setCart(CartInterface $cart): void + { + $this->cart = $cart; + } + + public function getType(): string + { + return CartSessionType::IDENTIFIER; + } +} diff --git a/code_samples/collaboration/src/Collaboration/Cart/CartSessionType.php b/code_samples/collaboration/src/Collaboration/Cart/CartSessionType.php new file mode 100644 index 0000000000..93c9ab3023 --- /dev/null +++ b/code_samples/collaboration/src/Collaboration/Cart/CartSessionType.php @@ -0,0 +1,36 @@ +getScopes(), true); + } + + public function getScopes(): array + { + return [ + self::SCOPE_VIEW, + self::SCOPE_EDIT, + ]; + } +} diff --git a/code_samples/collaboration/src/Collaboration/Cart/CartSessionUpdateStruct.php b/code_samples/collaboration/src/Collaboration/Cart/CartSessionUpdateStruct.php new file mode 100644 index 0000000000..ecf38e2f5e --- /dev/null +++ b/code_samples/collaboration/src/Collaboration/Cart/CartSessionUpdateStruct.php @@ -0,0 +1,13 @@ +repository->sudo(fn (): CartInterface => $this->cartService->getCart($identifier)); + + return true; + }; + + return $this->proxyGenerator->createProxy(CartInterface::class, $initializer); + } +} diff --git a/code_samples/collaboration/src/Collaboration/Cart/Mapper/CartProxyMapperInterface.php b/code_samples/collaboration/src/Collaboration/Cart/Mapper/CartProxyMapperInterface.php new file mode 100644 index 0000000000..37f4bc18ed --- /dev/null +++ b/code_samples/collaboration/src/Collaboration/Cart/Mapper/CartProxyMapperInterface.php @@ -0,0 +1,10 @@ + + */ +final readonly class CartSessionDomainMapper implements SessionDomainMapperInterface +{ + public function __construct( + private CartProxyMapperInterface $cartProxyMapper, + private UserProxyDomainMapperInterface $userDomainMapper, + private ParticipantCollectionDomainMapperInterface $participantCollectionDomainMapper + ) { + } + + /** + * @param \App\Collaboration\Cart\Persistence\Values\CartSession $data + */ + public function fromPersistence(SessionData $data): SessionInterface + { + return new CartSession( + $data->getId(), + $this->cartProxyMapper->createCartProxy($data->getCartIdentifier()), + $data->getToken(), + $this->userDomainMapper->createUserProxy($data->getOwnerId()), + $this->participantCollectionDomainMapper->createParticipantCollectionProxy($data->getId()), + $data->isActive(), + $data->hasPublicLink(), + $data->getCreatedAt(), + $data->getUpdatedAt(), + ); + } +} diff --git a/code_samples/collaboration/src/Collaboration/Cart/Mapper/CartSessionPersistenceMapper.php b/code_samples/collaboration/src/Collaboration/Cart/Mapper/CartSessionPersistenceMapper.php new file mode 100644 index 0000000000..380ecf6b06 --- /dev/null +++ b/code_samples/collaboration/src/Collaboration/Cart/Mapper/CartSessionPersistenceMapper.php @@ -0,0 +1,51 @@ +getToken(); + $owner = $createStruct->getOwner(); + $hasPublicLink = $createStruct->hasPublicLink(); + + assert($token !== null); + assert($owner !== null); + assert($hasPublicLink !== null); + + return new CartSessionCreateStruct( + $token, + $createStruct->getCart()->getIdentifier(), + $owner->getUserId(), + $createStruct->isActive(), + $hasPublicLink, + new \DateTimeImmutable(), + new \DateTimeImmutable() + ); + } + + public function toPersistenceUpdateStruct( + SessionInterface $session, + SessionUpdateStruct $updateStruct + ): PersistenceSessionUpdateStruct { + return new CartSessionUpdateStruct( + $session->getId(), + $updateStruct->getToken(), + ($updateStruct->getOwner() ?? $session->getOwner())->getUserId() + ); + } +} diff --git a/code_samples/collaboration/src/Collaboration/Cart/PermissionResolverDecorator.php b/code_samples/collaboration/src/Collaboration/Cart/PermissionResolverDecorator.php new file mode 100644 index 0000000000..8aba2621ec --- /dev/null +++ b/code_samples/collaboration/src/Collaboration/Cart/PermissionResolverDecorator.php @@ -0,0 +1,94 @@ +getObject(); + if ($this->nested === false && $this->isCartPolicy($policy) && $object instanceof CartInterface && $this->isSharedCart($object)) { + return true; + } + + return $this->innerPermissionResolver->canUser($policy); + } + + public function assertPolicy(PolicyInterface $policy): void + { + $object = $policy->getObject(); + if ($this->nested === false && $this->isCartPolicy($policy) && $object instanceof CartInterface && $this->isSharedCart($object)) { + return; + } + + $this->innerPermissionResolver->assertPolicy($policy); + } + + private function isCartPolicy(PolicyInterface $policy): bool + { + return $policy instanceof CartView || $policy instanceof CartEdit; + } + + private function isSharedCart(?CartInterface $cart): bool + { + if ($cart === null) { + return false; + } + + try { + $this->nested = true; + + /** @var \App\Collaboration\Cart\CartSession $session */ + $session = $this->getCurrentCartCollaborationSession(); + if ($session !== null) { + try { + return $cart->getId() === $session->getCart()->getId(); + } catch (NotFoundException) { + } + } + } finally { + $this->nested = false; + } + + return false; + } + + private function getCurrentCartCollaborationSession(): ?CartSession + { + $token = $this->requestStack->getSession()->get(self::COLLABORATION_SESSION_ID); + if ($token === null) { + return null; + } + + try { + $session = $this->sessionService->getSessionByToken($token); + if ($session instanceof CartSession) { + return $session; + } + } catch (NotFoundException|UnauthorizedException) { + } + + return null; + } +} diff --git a/code_samples/collaboration/src/Collaboration/Cart/Persistence/Gateway/DatabaseGateway.php b/code_samples/collaboration/src/Collaboration/Cart/Persistence/Gateway/DatabaseGateway.php new file mode 100644 index 0000000000..8ecca9a74b --- /dev/null +++ b/code_samples/collaboration/src/Collaboration/Cart/Persistence/Gateway/DatabaseGateway.php @@ -0,0 +1,71 @@ + + * + * @template-implements \Ibexa\Collaboration\Persistence\Session\Inner\GatewayInterface + */ +final class DatabaseGateway extends AbstractDoctrineDatabase implements GatewayInterface +{ + public const string DISCRIMINATOR = 'cart'; + + protected function buildMetadata(): DoctrineSchemaMetadataInterface + { + return new DoctrineSchemaMetadata( + $this->connection, + null, + $this->getTableName(), + [ + DatabaseSchema::COLUMN_ID => Types::INTEGER, + DatabaseSchema::COLUMN_CART_IDENTIFIER => Types::STRING, + ], + [DatabaseSchema::COLUMN_ID] + ); + } + + protected function getTableName(): string + { + return DatabaseSchema::TABLE_NAME; + } + + public function getDiscriminator(): string + { + return self::DISCRIMINATOR; + } + + /** + * @param \App\Collaboration\Cart\Persistence\Values\CartSessionCreateStruct $createStruct + */ + public function create(int $sessionId, AbstractSessionCreateStruct $createStruct): void + { + $this->doInsert([ + DatabaseSchema::COLUMN_ID => $sessionId, + DatabaseSchema::COLUMN_CART_IDENTIFIER => $createStruct->getCartIdentifier(), + ]); + } + + /** + * @param \Ibexa\Collaboration\Persistence\Values\AbstractSessionUpdateStruct $updateStruct + */ + public function update(AbstractSessionUpdateStruct $updateStruct): void + { + // There is nothing to update + } +} diff --git a/code_samples/collaboration/src/Collaboration/Cart/Persistence/Gateway/DatabaseSchema.php b/code_samples/collaboration/src/Collaboration/Cart/Persistence/Gateway/DatabaseSchema.php new file mode 100644 index 0000000000..87a556f269 --- /dev/null +++ b/code_samples/collaboration/src/Collaboration/Cart/Persistence/Gateway/DatabaseSchema.php @@ -0,0 +1,16 @@ + + */ +final class Mapper implements MapperInterface +{ + public function extractFromRow(array $row): AbstractSession + { + return new CartSession( + $row['id'], + $row['cart_cart_identifier'], + $row['token'], + $row['owner_id'], + $row['is_active'], + $row['has_public_link'], + $row['created_at'], + $row['updated_at'] + ); + } +} diff --git a/code_samples/collaboration/src/Collaboration/Cart/Persistence/Values/CartSession.php b/code_samples/collaboration/src/Collaboration/Cart/Persistence/Values/CartSession.php new file mode 100644 index 0000000000..dd58482adf --- /dev/null +++ b/code_samples/collaboration/src/Collaboration/Cart/Persistence/Values/CartSession.php @@ -0,0 +1,27 @@ +cartIdentifier; + } +} diff --git a/code_samples/collaboration/src/Collaboration/Cart/Persistence/Values/CartSessionCreateStruct.php b/code_samples/collaboration/src/Collaboration/Cart/Persistence/Values/CartSessionCreateStruct.php new file mode 100644 index 0000000000..875dc6a87a --- /dev/null +++ b/code_samples/collaboration/src/Collaboration/Cart/Persistence/Values/CartSessionCreateStruct.php @@ -0,0 +1,37 @@ +cartIdentifier; + } + + public function setCartIdentifier(string $cartIdentifier): void + { + $this->cartIdentifier = $cartIdentifier; + } + + public function getDiscriminator(): string + { + return CartSessionType::IDENTIFIER; + } +} diff --git a/code_samples/collaboration/src/Collaboration/Cart/Persistence/Values/CartSessionUpdateStruct.php b/code_samples/collaboration/src/Collaboration/Cart/Persistence/Values/CartSessionUpdateStruct.php new file mode 100644 index 0000000000..471a5f7fbd --- /dev/null +++ b/code_samples/collaboration/src/Collaboration/Cart/Persistence/Values/CartSessionUpdateStruct.php @@ -0,0 +1,14 @@ +createForm( + ShareCartType::class, + null, + [ + 'method' => 'POST', + ] + ); + + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var \App\Form\Data\ShareCartData $data */ + $data = $form->getData(); + + // Handle the form submission + $cart = $this->cartResolver->resolveCart(); + + $session = $this->sessionService->createSession( + new CartSessionCreateStruct($cart) + ); + + $email = $data->getEmail(); + if ($email === null) { + throw new InvalidArgumentException('Email cannot be null'); + } + + $this->sessionService->addParticipant( + $session, + new ExternalParticipantCreateStruct( + $email, + CartSessionType::SCOPE_EDIT + ) + ); + + return $this->render( + '@ibexadesign/cart/share_result.html.twig', + [ + 'session' => $session, + ] + ); + } + + return $this->render( + '@ibexadesign/cart/share.html.twig', + [ + 'form' => $form->createView(), + ] + ); + } +} diff --git a/code_samples/collaboration/src/Controller/ShareCartJoinController.php b/code_samples/collaboration/src/Controller/ShareCartJoinController.php new file mode 100644 index 0000000000..4c9ecc209b --- /dev/null +++ b/code_samples/collaboration/src/Controller/ShareCartJoinController.php @@ -0,0 +1,37 @@ +sessionService->getSessionByToken($token); + if ($session instanceof CartSession) { + $request->getSession()->set(self::CURRENT_COLLABORATION_SESSION, $session->getToken()); + + return $this->redirectToRoute('ibexa.cart.view', [ + 'identifier' => $session->getCart()->getIdentifier(), + ]); + } + + throw $this->createAccessDeniedException(); + } +} diff --git a/code_samples/collaboration/src/Form/Data/ShareCartData.php b/code_samples/collaboration/src/Form/Data/ShareCartData.php new file mode 100644 index 0000000000..37c2e0cc2c --- /dev/null +++ b/code_samples/collaboration/src/Form/Data/ShareCartData.php @@ -0,0 +1,21 @@ +email; + } + + public function setEmail(?string $email): void + { + $this->email = $email; + } +} diff --git a/code_samples/collaboration/src/Form/Type/ShareCartType.php b/code_samples/collaboration/src/Form/Type/ShareCartType.php new file mode 100644 index 0000000000..86f2369bcb --- /dev/null +++ b/code_samples/collaboration/src/Form/Type/ShareCartType.php @@ -0,0 +1,32 @@ + + */ +final class ShareCartType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('email', EmailType::class, [ + 'label' => 'E-mail', + ])->add('submit', SubmitType::class, [ + 'label' => 'Share', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => ShareCartData::class, + ]); + } +} diff --git a/code_samples/collaboration/src/Query/Search.php b/code_samples/collaboration/src/Query/Search.php index 71cea24df5..b4f7dfbabc 100644 --- a/code_samples/collaboration/src/Query/Search.php +++ b/code_samples/collaboration/src/Query/Search.php @@ -1,5 +1,4 @@ - + Cart has been shared successfully! Link to session:  + + {{ url('app.shared_cart.join', { token: session.getToken() }) }} + +

+{% endblock %} diff --git a/code_samples/collaboration/templates/themes/storefront/cart/view.html.twig b/code_samples/collaboration/templates/themes/storefront/cart/view.html.twig new file mode 100644 index 0000000000..922e21be24 --- /dev/null +++ b/code_samples/collaboration/templates/themes/storefront/cart/view.html.twig @@ -0,0 +1,8 @@ +{% extends '@IbexaStorefront/themes/storefront/cart/view.html.twig' %} + +{% block content %} +
+ Share cart +
+ {{ parent() }} +{% endblock %} diff --git a/docs/content_management/collaborative_editing/collaborative_editing.md b/docs/content_management/collaborative_editing/collaborative_editing.md index 46367f5c2d..3b6e384590 100644 --- a/docs/content_management/collaborative_editing/collaborative_editing.md +++ b/docs/content_management/collaborative_editing/collaborative_editing.md @@ -30,6 +30,7 @@ This feature also introduces new dashboard tabs for managing shared drafts and j "content_management/collaborative_editing/collaborative_editing_api", "api/event_reference/collaboration_events", ("https://doc.ibexa.co/en/4.6/api/rest_api/rest_api_reference/rest_api_reference.html#collaborative-editing", "REST API Reference", "See the available endpoints for Collaborative editing"), +"content_management/collaborative_editing/extend_collaborative_editing", "search/collaboration_search_reference/collaboration_criteria", "search/collaboration_search_reference/collaboration_sort_clauses", ], columns=4) =]] diff --git a/docs/content_management/collaborative_editing/extend_collaborative_editing.md b/docs/content_management/collaborative_editing/extend_collaborative_editing.md new file mode 100644 index 0000000000..46e9293a60 --- /dev/null +++ b/docs/content_management/collaborative_editing/extend_collaborative_editing.md @@ -0,0 +1,321 @@ +--- +description: Extend Collaborative editing +month_change: true +--- + +# Extend Collaborative editing + +Thanks to the ability to extend the [Collaborative editing](collaborative_editing_guide.md) feature, you can introduce additional functionalities to enhance workflows not only in the context of content editing but also when working with products. +In the example below, you will learn how to extend this feature to enable a shared Cart functionality in the Commerce system. + +!!! tip + + If you prefer learning from videos, watch the Ibexa Summit 2025 presentation that covers the Collaborative editing feature: + + [_Collaboration: greater than the sum of the parts_](https://www.youtube.com/watch?v=dRB-SDlgX0I) by Marek Nocoń + +## Create tables to hold Cart session data + +First, set up the database layer and define the collaboration context, in this example, Cart. +Create the necessary tables to store the data and to link the collaboration session with the Cart you want to share. + +In the `data/schema.sql` file, create a database table to store a reference to the session context. +In this example, the context is a shopping Cart, identified by `cart_identifier` and linked to the collaboration session through the Cart’s numeric ID stored in the database. + +=== "MySQL" + + ``` sql + [[= include_file('code_samples/collaboration/ibexa_collaboration_cart.mysql.sql', 0, None, ' ') =]] + ``` + +=== "PostgreSQL" + + ``` sql + [[= include_file('code_samples/collaboration/ibexa_collaboration_cart.postgresql.sql', 0, None, ' ') =]] + ``` + +## Set up persistence layer + +Now you need to prepare the persistence layer, which is responsible for storing, retrieving, and managing collaboration session and Cart data in the database. + +It ensures that when a user creates, joins, or updates a Cart session, the system can track session status, participants, and permissions. + +### Implement persistence gateway + +The Gateway is the layer that connects the collaboration feature to the database. +It handles all the create, read, update, and delete operations for collaboration sessions, ensuring that session data is stored and retrieved correctly. + +It also uses a Discriminator to specify the session type, so it can interact with the correct tables and data structures. +This way, the system uses the correct Gateway to get or save data for each session type. + +When creating the Database Gateways and mappers, you can use the built-in service tag: + +- `ibexa.collaboration.persistence.session.gateway` - for the database gateway: + + ```yaml + tags: + - { name: 'ibexa.collaboration.persistence.session.gateway', discriminator: 'my_session_type' } + ``` + +- `ibexa.collaboration.persistence.session.mapper` - for the mapper that creates a session from a persistence raw row: + + ```yaml + tags: + - { name: 'ibexa.collaboration.persistence.session.mapper', discriminator: 'my_session_type' } + ``` + +- `ibexa.collaboration.service.session.domain.mapper` - for the mapper that creates a session from a persistence object: + + ```yaml + tags: + - { name: 'ibexa.collaboration.service.session.domain.mapper', type: App\…\MyPersistentSession } + ``` + +- `ibexa.collaboration.service.session.persistence.mapper` - for the mapper that converts a session into a structure used to create or update persistence: + + ```yaml + tags: + - { name: 'ibexa.collaboration.service.session.persistence.mapper', type: 'my_session_type' } + ``` + +In the `src/Collaboration/Cart/Persistence/Gateway/` directory, create the following files: + +- `DatabaseSchema` - defines the database tables needed to store shared Cart collaboration session data: + +``` php +[[= include_file('code_samples/collaboration/src/Collaboration/Cart/Persistence/Gateway/DatabaseSchema.php') =]] +``` + +- `DatabaseGateway` - implements the gateway logic for getting and retrieving shared Cart collaboration data from the database, using a Discriminator to indicate the type of session (in this case, a Cart session): + +``` php +[[= include_file('code_samples/collaboration/src/Collaboration/Cart/Persistence/Gateway/DatabaseGateway.php') =]] +``` + +### Define persistence Value objects + +Value objects describe how collaboration session data is represented in the database. +Persistence gateway uses them to store, retrieve, and manipulate session information, such as the session ID, associated Cart, participants, and scopes. + +``` yaml +[[= include_file('code_samples/collaboration/config/services.yaml', 33, 38) =]] +``` + +In the `src/Collaboration/Cart/Persistence/Values/` directory, create the following Value Objects: + +- `CartSession` - represents the Cart collaboration session data: + +``` php +[[= include_file('code_samples/collaboration/src/Collaboration/Cart/Persistence/Values/CartSession.php') =]] +``` + +- `CartSessionCreateStruct` - defines the data needed to create a new Cart collaboration session: + +``` php +[[= include_file('code_samples/collaboration/src/Collaboration/Cart/Persistence/Values/CartSessionCreateStruct.php') =]] +``` + +- `CartSessionUpdateStruct` - defines the data used to update an existing Cart collaboration session: + +``` php +[[= include_file('code_samples/collaboration/src/Collaboration/Cart/Persistence/Values/CartSessionUpdateStruct.php') =]] +``` + +### Create Cart session Struct objects + +The next step is to integrate the Public API with the database so that it can store and retrieve data from the tables created earlier. +You need to create new files to define the data that is passed into the public API. +This data is then used by the [`SessionService`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Collaboration-SessionServiceInterface.html) and public API handlers. + +In the `src/Collaboration/Cart/` directory, create the following Session Structs: + +- `CartSessionCreateStruct` - holds all necessary properties (like session token, participants, scopes, and the Cart reference) needed by the `SessionService` to create the shared Cart session: + +``` php +[[= include_file('code_samples/collaboration/src/Collaboration/Cart/CartSessionCreateStruct.php') =]] +``` + +- `CartSessionUpdateStruct` - defines the properties used to update an existing Cart collaboration session, including participants, scopes, and metadata: + +``` php +[[= include_file('code_samples/collaboration/src/Collaboration/Cart/CartSessionUpdateStruct.php') =]] +``` + +- `CartSession` - represents a Cart collaboration session, storing its ID, token, associated Cart, participants, and scope: + +``` php +[[= include_file('code_samples/collaboration/src/Collaboration/Cart/CartSession.php') =]] +``` + +- `CartSessionType` - defines the type of the collaboration session (in this case it indicates it’s a Cart session): + +``` php +[[= include_file('code_samples/collaboration/src/Collaboration/Cart/CartSessionType.php') =]] +``` + +## Create mappers + +Mappers convert session data into the format required by the database and pass it to the repository. + +In the `src/Collaboration/Cart/Mapper/` directory, create following mappers: + +- `CartProxyMapper` - creates a simplified version of the Cart with only the necessary data to reduce memory usage in collaboration sessions: + +``` php +[[= include_file('code_samples/collaboration/src/Collaboration/Cart/Mapper/CartProxyMapper.php') =]] +``` + +- `CartProxyMapperInterface` - defines how a Cart should be converted into a simplified object that is used in collaboration session and specifies what methods the mapper must implement: + +``` php +[[= include_file('code_samples/collaboration/src/Collaboration/Cart/Mapper/CartProxyMapperInterface.php') =]] +``` + +- `CartSessionDomainMapper` - builds the session object from persistence object: + +``` php +[[= include_file('code_samples/collaboration/src/Collaboration/Cart/Mapper/CartSessionDomainMapper.php') =]] +``` + +- `CartSessionPersistenceMapper` - prepares session data to be saved or updated in the database: + +``` php +[[= include_file('code_samples/collaboration/src/Collaboration/Cart/Mapper/CartSessionPersistenceMapper.php') =]] +``` + +Then, in the `src/Collaboration/Cart/Persistence/` directory, create the following mapper: + +- `Persistence/Mapper` - builds the session object from persistence row: + +``` php +[[= include_file('code_samples/collaboration/src/Collaboration/Cart/Persistence/Mapper.php') =]] +``` + +In `services.yaml`, declare and tags the gateway and the mappers: + +``` yaml +services: + # … +[[= include_file('code_samples/collaboration/config/services.yaml', 21, 42) =]] +``` + +## Allow participants to access Cart + +To enable collaboration, you must configure the appropriate permissions. +This involves decorating the `PermissionResolver` and `CartResolver`. + +This ensures that when a Cart is part of a Cart collaboration session, users can access it based on the defined permissions. +In all other cases, the system falls back to the default implementation. + +!!! caution "Decorating permissions" + + When decorating permissions, be careful to change the behavior only as necessary, to ensure that the Cart is shared only with the intended users. + +In the `src/Collaboration/Cart/` directory, create the following files: + +- `PermissionResolverDecorator` – customizes the permission resolver to handle access rules for Cart collaboration sessions. It allows participants to view or edit shared Carts while preserving default permission checks for all other cases. Here you can decide what scope is available for this collaboration session by choosing between `view` or `edit`: + +``` php +[[= include_file('code_samples/collaboration/src/Collaboration/Cart/PermissionResolverDecorator.php') =]] +``` + +- `CartResolverDecorator` – resolves the shared Carts in collaboration sessions by checking if a Cart belongs to a collaboration session: + +``` php +[[= include_file('code_samples/collaboration/src/Collaboration/Cart/CartResolverDecorator.php') =]] +``` + +In `services.yaml`, declare those decorator services associated with what they decorate: + +``` yaml +services: + # … +[[= include_file('code_samples/collaboration/config/services.yaml', 43) =]] +``` + +## Build dedicated controllers to manage Cart sharing flow + +To support Cart sharing, create controllers which handle the collaboration flow. +They are responsible for starting a sharing session, adding participants, and allowing users to join an existing shared Cart. + +You need to create two controllers: + +- `ShareCartCreateController` - creates the Cart collaboration session and adds participants +- `ShareCartJoinController` - allows to join the session + +### `ShareCartCreateController` + +This controller handles the request when you enter an email address of the user that you want to invite and submit it. +It captures the email address and checks whether the form has been submitted. +If yes, the form data is retrieved, and the `cartResolver` verifies whether there is currently a shared Cart. + +If a shared Cart exists, the Cart is retrieved and a session is created (`$cart` becomes the session context). +In the `addParticipant` step, the user whose email address was provided is added to the session and assigned a scope (either `view` or `edit`). + +``` php +[[= include_file('code_samples/collaboration/src/Controller/ShareCartCreateController.php') =]] +``` + +### `ShareCartJoinController` + +It enables joining a Cart session. +The session token created earlier is passed in the URL, and in the `join` action, the system attempts to retrieve the session associated with that token. +If the token is invalid, an exception is thrown to indicate that the session cannot be accessed. +If the session exists, the session parameter (`collaboration_session`) is retrieved and the session stores the token. +Finally, `redirectToRoute` redirects the user to the Cart view and passes the identifier of the shared Cart. + +``` php +[[= include_file('code_samples/collaboration/src/Controller/ShareCartJoinController.php') =]] +``` + +!!! caution "Session parameter" + + Avoid using a generic session parameter name such as `collaboration_session` (it's used here only for example purposes). + The user can participate in multiple sessions simultaneously (of one or many types), so using such name would cause the parameter to be constantly overwritten. + Therefore, active sessions should not be resolved based on such parameter. + +## Integrate with Symfony forms by adding forms and templates + +To support inviting users to a shared Cart, you need to create a dedicated form and a data class. +The form collects the email address of the user that you want to invite, and the data class is used to safely pass that information from the form to the controller. + +- `ShareCartType` - a simple form for entering an email address of the user you want to invite to share the Cart. The form contains a single input field where you enter the email address manually: + +``` php +[[= include_file('code_samples/collaboration/src/Form/Type/ShareCartType.php') =]] +``` + +- `ShareCartData` - a class that holds the email address submitted through the form and passes it to the controller: + +``` php +[[= include_file('code_samples/collaboration/src/Form/Data/ShareCartData.php') =]] +``` + +The last step is to integrate the new session type into your application by adding templates. +In this step, the view is rendered. + +You need to add the following Twig templates in the `src/templates/themes/storefront/cart/` directory: + +- `share` - defines the view for the Cart sharing form. It renders the form where a user can enter an email address to invite someone to collaborate on the Cart: + +``` php +[[= include_file('code_samples/collaboration/templates/themes/storefront/cart/share.html.twig') =]] +``` + +![Share email](img/share_email.png) + +- `share_result` - renders the result page after a Cart has been shared. If the shared Cart exists in the system, the created session object is passed to the view and displayed. A message like "Cart has been shared…" is displayed, along with a link to access the session: + +``` php +[[= include_file('code_samples/collaboration/templates/themes/storefront/cart/share_result.html.twig') =]] +``` + +![Share message](img/share_message.png) + +- `view` - shows the Cart page. It displays the Cart content and includes the “Share Cart” button: + +``` php +[[= include_file('code_samples/collaboration/templates/themes/storefront/cart/view.html.twig') =]] +``` + +![Share button](img/share_button.png) diff --git a/docs/content_management/collaborative_editing/img/share_button.png b/docs/content_management/collaborative_editing/img/share_button.png new file mode 100644 index 0000000000..01ee37de01 Binary files /dev/null and b/docs/content_management/collaborative_editing/img/share_button.png differ diff --git a/docs/content_management/collaborative_editing/img/share_email.png b/docs/content_management/collaborative_editing/img/share_email.png new file mode 100644 index 0000000000..4452a59db0 Binary files /dev/null and b/docs/content_management/collaborative_editing/img/share_email.png differ diff --git a/docs/content_management/collaborative_editing/img/share_message.png b/docs/content_management/collaborative_editing/img/share_message.png new file mode 100644 index 0000000000..7788d2d88d Binary files /dev/null and b/docs/content_management/collaborative_editing/img/share_message.png differ diff --git a/docs/search/collaboration_search_reference/collaboration_criteria.md b/docs/search/collaboration_search_reference/collaboration_criteria.md index b5b909674a..8ef1bed45a 100644 --- a/docs/search/collaboration_search_reference/collaboration_criteria.md +++ b/docs/search/collaboration_search_reference/collaboration_criteria.md @@ -50,7 +50,7 @@ Session Search Criteria are implementing the [CriterionInterface](/api/php_api/p The following example shows how you can use the criteria to find all the currently active sessions: -```php hl_lines="12-16" +```php hl_lines="11-15" [[= include_file('code_samples/collaboration/src/Query/Search.php') =]] ``` diff --git a/docs/search/collaboration_search_reference/collaboration_sort_clauses.md b/docs/search/collaboration_search_reference/collaboration_sort_clauses.md index be985fb453..33b943f937 100644 --- a/docs/search/collaboration_search_reference/collaboration_sort_clauses.md +++ b/docs/search/collaboration_search_reference/collaboration_sort_clauses.md @@ -33,7 +33,7 @@ Session Search Sort Clauses are implementing the [SortClauseInterface](/api/php_ The following example shows how to use them to sort the searched sessions: -```php hl_lines="18" +```php hl_lines="17" [[= include_file('code_samples/collaboration/src/Query/Search.php') =]] ``` diff --git a/mkdocs.yml b/mkdocs.yml index d6f04328b2..1b9bf392ea 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -288,6 +288,7 @@ nav: - Collaborative editing product guide: content_management/collaborative_editing/collaborative_editing_guide.md - Configure Collaborative editing: content_management/collaborative_editing/configure_collaborative_editing.md - Collaborative editing API: content_management/collaborative_editing/collaborative_editing_api.md + - Extend Collaborative editing: content_management/collaborative_editing/extend_collaborative_editing.md - Templating: - Templating: templating/templating.md - Render content: