Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,10 @@ passes.
bundle items resolver.
- `CatalogStorefrontGroupedProduct` / `...GroupedProductGraphQl`: the grouped
range / the deprecated price rewrite, the grouped items resolver.
- `CatalogStorefrontDownloadable` / `...DownloadableGraphQl`: the downloadable
range registration / the links and samples resolvers.
- `CatalogStorefrontDownloadable` / `...DownloadableGraphQl` / `...DownloadableFrontend`: the
downloadable range registration / the links and samples resolvers / the links and samples of
a rendered product page (`getLinks()` and `getSamples()` of the type from `optionsV2` and
`samples`, kept in core's memo keys; a link has a sample when the feed gives it an `infoUrl`).
- `CatalogStorefrontReview` / `...ReviewGraphQl` / `...ReviewFrontend`: the reviews and
rating feeds made to export like the modern ones, the review date field, the rating
position and entity, their writers (reviews become documents of their own, declared to the
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ runs core alone.
| A configurable's children, super attributes and lowest price on a rendered page | `...ConfigurableProductFrontend` | variant documents and configurable attributes |
| Bundle options, selections and price ranges on a rendered page | `...BundleProductFrontend` | child documents, price rows and stock limits |
| Grouped children and quantity validation on a rendered page | `...GroupedProductFrontend` | child documents and stock limits |
| Downloadable links and samples on a rendered page | `...DownloadableFrontend` | link ids and sample ids |

## Fields the package adds

Expand Down Expand Up @@ -504,7 +505,7 @@ the base module and the GraphQl module.
| [`...ConfigurableProduct`](src/CatalogStorefrontConfigurableProduct) / [`...GraphQl`](src/CatalogStorefrontConfigurableProductGraphQl) / [`...Frontend`](src/CatalogStorefrontConfigurableProductFrontend) | Variants and the configurable range / options, variants, selection / a configurable on a rendered page |
| [`...BundleProduct`](src/CatalogStorefrontBundleProduct) / [`...GraphQl`](src/CatalogStorefrontBundleProductGraphQl) / [`...Frontend`](src/CatalogStorefrontBundleProductFrontend) | Bundle feed fields and range / bundle items and price details / rendered bundle options and prices |
| [`...GroupedProduct`](src/CatalogStorefrontGroupedProduct) / [`...GraphQl`](src/CatalogStorefrontGroupedProductGraphQl) / [`...Frontend`](src/CatalogStorefrontGroupedProductFrontend) | Grouped range / grouped items / rendered children and quantity validation |
| [`...Downloadable`](src/CatalogStorefrontDownloadable) / [`...GraphQl`](src/CatalogStorefrontDownloadableGraphQl) | The downloadable range / links and samples |
| [`...Downloadable`](src/CatalogStorefrontDownloadable) / [`...GraphQl`](src/CatalogStorefrontDownloadableGraphQl) / [`...Frontend`](src/CatalogStorefrontDownloadableFrontend) | The downloadable range / links and samples / rendered links and samples |
| [`...Review`](src/CatalogStorefrontReview) / [`...GraphQl`](src/CatalogStorefrontReviewGraphQl) / [`...Frontend`](src/CatalogStorefrontReviewFrontend) | Review and rating feeds / rating summary, breakdown and reviews / the rating collection of the rendered review form |
| [`...SnowdogMenu`](src/CatalogStorefrontSnowdogMenu) | Snowdog's menus as documents from a feed, and the menus, nodes and node data of a rendered page from them, every node type. Registered only where Snowdog_Menu is installed |
| [`...Swatches`](src/CatalogStorefrontSwatches) / [`...GraphQl`](src/CatalogStorefrontSwatchesGraphQl) | The swatch of every option on the attribute documents / the `swatch` fields of GraphQL Catalog from them |
Expand Down
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"graphcommerce/module-catalog-storefront-statement-gate": "self.version",
"graphcommerce/module-catalog-storefront-bundle-product-frontend": "self.version",
"graphcommerce/module-catalog-storefront-grouped-product-frontend": "self.version",
"graphcommerce/module-catalog-storefront-downloadable-frontend": "self.version",
"graphcommerce/module-catalog-storefront-swatches": "self.version",
"graphcommerce/module-catalog-storefront-swatches-graph-ql": "self.version",
"graphcommerce/module-catalog-storefront-url-rewrite": "self.version",
Expand Down Expand Up @@ -121,6 +122,7 @@
"src/CatalogStorefrontReviewFrontend/registration.php",
"src/CatalogStorefrontBundleProductFrontend/registration.php",
"src/CatalogStorefrontGroupedProductFrontend/registration.php",
"src/CatalogStorefrontDownloadableFrontend/registration.php",
"src/CatalogStorefrontSwatches/registration.php",
"src/CatalogStorefrontSwatchesGraphQl/registration.php",
"src/CatalogStorefrontUrlRewrite/registration.php",
Expand Down Expand Up @@ -163,6 +165,7 @@
"GraphCommerce\\CatalogStorefrontSearch\\": "src/CatalogStorefrontSearch/",
"GraphCommerce\\CatalogStorefrontBundleProductFrontend\\": "src/CatalogStorefrontBundleProductFrontend/",
"GraphCommerce\\CatalogStorefrontGroupedProductFrontend\\": "src/CatalogStorefrontGroupedProductFrontend/",
"GraphCommerce\\CatalogStorefrontDownloadableFrontend\\": "src/CatalogStorefrontDownloadableFrontend/",
"GraphCommerce\\CatalogStorefrontSwatches\\": "src/CatalogStorefrontSwatches/",
"GraphCommerce\\CatalogStorefrontSwatchesGraphQl\\": "src/CatalogStorefrontSwatchesGraphQl/",
"GraphCommerce\\CatalogStorefrontUrlRewrite\\": "src/CatalogStorefrontUrlRewrite/",
Expand Down
115 changes: 115 additions & 0 deletions src/CatalogStorefrontDownloadableFrontend/Plugin/LinksFromDocument.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);

namespace GraphCommerce\CatalogStorefrontDownloadableFrontend\Plugin;

use GraphCommerce\CatalogStorefront\Model\DocumentReadException;
use GraphCommerce\CatalogStorefrontApi\Read\ProductDocumentsInterface;
use Magento\Catalog\Model\Product;
use Magento\Downloadable\Model\Link;
use Magento\Downloadable\Model\LinkFactory;
use Magento\Downloadable\Model\Product\Type;
use Magento\Downloadable\Model\SampleFactory;
use Magento\Framework\Data\Collection;
use Magento\Framework\Data\CollectionFactory;
use Magento\Framework\GraphQl\Query\Uid;

/**
* The links and samples of a downloadable product that carries a document, from its `optionsV2`
* downloadable values and its `samples`, in the memo keys core's type reads first. A link has a
* sample when the feed gives it an `infoUrl`.
*/
class LinksFromDocument
{
public function __construct(
private readonly LinkFactory $linkFactory,
private readonly SampleFactory $sampleFactory,
private readonly CollectionFactory $collectionFactory,
private readonly Uid $uid,
) {
}

/**
* @param Type $subject
* @param \Closure $proceed
* @param Product $product
* @return Link[]
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function aroundGetLinks(Type $subject, \Closure $proceed, $product)
{
$document = $product->getData(ProductDocumentsInterface::DOCUMENT_KEY);
if (!is_array($document) || $product->getDownloadableLinks() !== null) {
return $proceed($product);
}

$values = [];
foreach ((array)($document['optionsV2'] ?? []) as $option) {
if (($option['type'] ?? null) === 'downloadable') {
array_push($values, ...(array)($option['values'] ?? []));
}
}

$links = [];
foreach ($values as $value) {
$parts = explode('/', (string)$this->uid->decode((string)($value['id'] ?? '')));
if (count($parts) !== 2 || $parts[0] !== 'downloadable' || !ctype_digit($parts[1])) {
throw new DocumentReadException('Catalog downloadable link UID is invalid: ' . ($value['id'] ?? ''));
}
$link = $this->linkFactory->create()->setData([
'link_id' => $parts[1],
'product_id' => (string)$product->getId(),
'sort_order' => (string)($value['sortOrder'] ?? 0),
'title' => $value['label'] ?? null,
'price' => (string)($value['price'] ?? 0),
'number_of_downloads' => (string)($value['qty'] ?? 0),
'sample_url' => $value['infoUrl'] ?? null,
]);
$link->setProduct($product);
$links[$parts[1]] = $link;
}
uasort($links, static fn(Link $a, Link $b) =>
[(int)$a->getSortOrder(), (int)$a->getId()] <=> [(int)$b->getSortOrder(), (int)$b->getId()]);
$product->setDownloadableLinks($links);

return $links;
}

/**
* @param Type $subject
* @param \Closure $proceed
* @param Product $product
* @return Collection
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function aroundGetSamples(Type $subject, \Closure $proceed, $product)
{
$document = $product->getData(ProductDocumentsInterface::DOCUMENT_KEY);
if (!is_array($document) || $product->getDownloadableSamples() !== null) {
return $proceed($product);
}

$samples = [];
foreach ((array)($document['samples'] ?? []) as $sample) {
if (!preg_match('~/sample_id/(\d+)~', (string)($sample['resource']['url'] ?? ''), $match)) {
throw new DocumentReadException('Catalog downloadable sample URL names no sample: ' . $product->getId());
}
$samples[] = $this->sampleFactory->create()->setData([
'sample_id' => $match[1],
'product_id' => (string)$product->getId(),
'sort_order' => (string)($sample['sortOrder'] ?? 0),
'title' => $sample['resource']['label'] ?? null,
]);
}
usort($samples, static fn($a, $b) =>
[(int)$a->getSortOrder(), (int)$a->getId()] <=> [(int)$b->getSortOrder(), (int)$b->getId()]);

$collection = $this->collectionFactory->create();
foreach ($samples as $sample) {
$collection->addItem($sample);
}
$product->setDownloadableSamples($collection);

return $collection;
}
}
16 changes: 16 additions & 0 deletions src/CatalogStorefrontDownloadableFrontend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# GraphCommerce_CatalogStorefrontDownloadableFrontend

The links and samples of a downloadable product page served from documents, for a Luma or
Hyvä theme, under the `serve_pdp` setting of GraphCommerce_CatalogStorefrontProductFrontend and
its `X-Catalog-Storefront` header. Frontend area only.

- [`Plugin/LinksFromDocument`](Plugin/LinksFromDocument.php) answers `getLinks()` and
`getSamples()` of the downloadable type from the document's downloadable `optionsV2` values and
its `samples`, and keeps them in the product's `downloadable_links` and `downloadable_samples`,
which core's type reads first. A link has a sample when the feed gives it an `infoUrl`.
- It registers `downloadable` with the option types the product page plugin supports.
- A link UID or a sample URL that names no id stops the page with `DocumentReadException`.

```sh
bin/magento catalog-storefront:parity:detail https://shop.example
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);

namespace GraphCommerce\CatalogStorefrontDownloadableFrontend\Test\Unit\Plugin;

use GraphCommerce\CatalogStorefront\Model\DocumentReadException;
use GraphCommerce\CatalogStorefrontApi\Read\ProductDocumentsInterface;
use GraphCommerce\CatalogStorefrontDownloadableFrontend\Plugin\LinksFromDocument;
use Magento\Catalog\Model\Product;
use Magento\Downloadable\Model\Link;
use Magento\Downloadable\Model\LinkFactory;
use Magento\Downloadable\Model\Product\Type;
use Magento\Downloadable\Model\Sample;
use Magento\Downloadable\Model\SampleFactory;
use Magento\Framework\Data\Collection;
use Magento\Framework\Data\Collection\EntityFactoryInterface;
use Magento\Framework\Data\CollectionFactory;
use Magento\Framework\GraphQl\Query\Uid;
use PHPUnit\Framework\TestCase;

class LinksFromDocumentTest extends TestCase
{
private function plugin(): LinksFromDocument
{
$linkFactory = $this->createStub(LinkFactory::class);
$linkFactory->method('create')->willReturnCallback(static function () {
$link = (new \ReflectionClass(Link::class))->newInstanceWithoutConstructor();
$link->setIdFieldName('link_id');

return $link;
});
$sampleFactory = $this->createStub(SampleFactory::class);
$sampleFactory->method('create')->willReturnCallback(static function () {
$sample = (new \ReflectionClass(Sample::class))->newInstanceWithoutConstructor();
$sample->setIdFieldName('sample_id');

return $sample;
});
$collectionFactory = $this->createStub(CollectionFactory::class);
$collectionFactory->method('create')->willReturnCallback(
fn() => new Collection($this->createStub(EntityFactoryInterface::class))
);
$uid = $this->createStub(Uid::class);
$uid->method('decode')->willReturnCallback('base64_decode');

return new LinksFromDocument($linkFactory, $sampleFactory, $collectionFactory, $uid);
}

private function product(?array $document): Product
{
$product = $this->getMockBuilder(Product::class)->disableOriginalConstructor()->onlyMethods([])->getMock();
$product->setIdFieldName('entity_id');
$product->setData(['entity_id' => '42']);
if ($document !== null) {
$product->setData(ProductDocumentsInterface::DOCUMENT_KEY, $document);
}

return $product;
}

private static function link(int $id, int $sortOrder, ?string $infoUrl): array
{
return [
'id' => base64_encode('downloadable/' . $id),
'label' => 'Link ' . $id,
'sortOrder' => $sortOrder,
'price' => 2.5,
'qty' => 3,
'infoUrl' => $infoUrl,
];
}

public function testLinksComeFromTheDownloadableOptionInSortOrder(): void
{
$product = $this->product(['optionsV2' => [
['type' => 'downloadable', 'values' => [self::link(5, 2, null), self::link(4, 1, 'https://shop/sample/4')]],
]]);
$core = static fn() => self::fail('The core link reader was called.');

$links = $this->plugin()->aroundGetLinks($this->createStub(Type::class), $core, $product);

self::assertSame(['4', '5'], array_map('strval', array_keys($links)));
self::assertSame('Link 4', $links['4']->getTitle());
self::assertSame('2.5', $links['4']->getPrice());
self::assertSame('3', $links['4']->getNumberOfDownloads());
self::assertSame('https://shop/sample/4', $links['4']->getSampleUrl());
self::assertNull($links['5']->getSampleUrl());
self::assertSame($product, $links['4']->getProduct());
self::assertSame($links, $product->getDownloadableLinks());
}

public function testInvalidLinkUidStopsTheRead(): void
{
$product = $this->product(['optionsV2' => [
['type' => 'downloadable', 'values' => [['id' => base64_encode('bundle/1/2/3')]]],
]]);

$this->expectException(DocumentReadException::class);
$this->plugin()->aroundGetLinks($this->createStub(Type::class), static fn() => [], $product);
}

public function testSamplesComeFromTheDocumentInSortOrder(): void
{
$product = $this->product(['samples' => [
['resource' => ['label' => 'Second', 'url' => 'https://shop/downloadable/download/sample/sample_id/12'], 'sortOrder' => '2'],
['resource' => ['label' => 'First', 'url' => 'https://shop/downloadable/download/sample/sample_id/13'], 'sortOrder' => '1'],
]]);
$core = static fn() => self::fail('The core sample reader was called.');

$samples = $this->plugin()->aroundGetSamples($this->createStub(Type::class), $core, $product);

self::assertSame([13, 12], array_map('intval', $samples->getAllIds()));
self::assertSame('First', $samples->getFirstItem()->getTitle());
self::assertSame($samples, $product->getDownloadableSamples());
}

public function testSampleUrlWithoutIdStopsTheRead(): void
{
$product = $this->product(['samples' => [['resource' => ['url' => 'https://shop/other']]]]);

$this->expectException(DocumentReadException::class);
$this->plugin()->aroundGetSamples($this->createStub(Type::class), static fn() => null, $product);
}

public function testProductWithoutADocumentGoesToCore(): void
{
$product = $this->product(null);
$type = $this->createStub(Type::class);

self::assertSame(['core'], $this->plugin()->aroundGetLinks($type, static fn() => ['core'], $product));
self::assertSame('core', $this->plugin()->aroundGetSamples($type, static fn() => 'core', $product));
}
}
22 changes: 22 additions & 0 deletions src/CatalogStorefrontDownloadableFrontend/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "graphcommerce/module-catalog-storefront-downloadable-frontend",
"description": "Serves the links and samples of downloadable product pages from catalog documents",
"type": "magento2-module",
"license": "MIT",
"require": {
"php": "~8.1.0|~8.2.0|~8.3.0|~8.4.0",
"magento/framework": "*",
"magento/module-catalog": "*",
"magento/module-downloadable": "*",
"graphcommerce/module-catalog-storefront-product-frontend": "self.version",
"graphcommerce/module-catalog-storefront-downloadable": "self.version"
},
"autoload": {
"files": [
"registration.php"
],
"psr-4": {
"GraphCommerce\\CatalogStorefrontDownloadableFrontend\\": ""
}
}
}
9 changes: 9 additions & 0 deletions src/CatalogStorefrontDownloadableFrontend/etc/frontend/di.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="GraphCommerce\CatalogStorefrontProductFrontend\Plugin\Detail\ProductDocument">
<arguments><argument name="supportedOptionTypes" xsi:type="array"><item name="downloadable" xsi:type="boolean">true</item></argument></arguments>
</type>
<type name="Magento\Downloadable\Model\Product\Type">
<plugin name="catalog_storefront_downloadable_documents" type="GraphCommerce\CatalogStorefrontDownloadableFrontend\Plugin\LinksFromDocument"/>
</type>
</config>
10 changes: 10 additions & 0 deletions src/CatalogStorefrontDownloadableFrontend/etc/module.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="GraphCommerce_CatalogStorefrontDownloadableFrontend">
<sequence>
<module name="GraphCommerce_CatalogStorefrontProductFrontend"/>
<module name="GraphCommerce_CatalogStorefrontDownloadable"/>
<module name="Magento_Downloadable"/>
</sequence>
</module>
</config>
6 changes: 6 additions & 0 deletions src/CatalogStorefrontDownloadableFrontend/registration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php
declare(strict_types=1);

use Magento\Framework\Component\ComponentRegistrar;

ComponentRegistrar::register(ComponentRegistrar::MODULE, 'GraphCommerce_CatalogStorefrontDownloadableFrontend', __DIR__);