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
12 changes: 12 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"mcpServers": {
"playwright": {
"type": "stdio",
"command": "npx",
"args": [
"@playwright/mcp@latest"
],
"env": {}
}
}
}
27 changes: 27 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,33 @@ Follow clean code principles and SOLID design patterns when working with this co
- Test form submission, validation, and data transformation
- Ensure tests are isolated and don't depend on external state
- Test both happy path and edge cases
- **Every UI feature must be verified in a real browser with Playwright (MCP).** Automated
PHPUnit tests are not enough for admin/shop UI: after building or changing anything users
interact with (grids, forms, menu items, admin actions, public pages), drive it end-to-end with
the Playwright MCP tools against the running test app and confirm it actually works — pages load
(no 500s), forms submit and persist, grid row actions run, and generated output is reachable.
- Run the test app over HTTPS via `symfony server:start -d` in `tests/Application` (note the
port it reports — it is **not** necessarily `:8000` if another project already holds that port).
Log in at `/admin/login` with `sylius` / `sylius` (load fixtures first:
`bin/console sylius:fixtures:load -n --env=dev`). Build assets with `yarn build`. The test app
**inlines `@sylius-ui/frontend`'s build dependencies** directly in `package.json` (instead of the
meta-package) so versions are controllable, with two deliberate pins:
- `sass` is pinned to **`1.77.8`** (dart-sass; the latest requires Node ≥20.19, which would force
Node 20+) so the build runs on Node 18.
- a `resolutions` entry dedupes **`jquery` to `3.7.1`**. Without it, `jquery.dirtyforms` (dep
`"jquery": ">=1.4.2"`) and `semantic-ui-css` each pull their own `jquery@4`, so the plugin
attaches `.dirtyForms` to a different jQuery instance than Sylius's admin entry uses → the
admin JS throws `dirtyForms is not a function` and **silently breaks every admin form
interaction** (collection add/remove, etc.). One jQuery fixes it.

`tests/Application/.nvmrc` pins **Node 18**. With Node 18 active (`nvm use`), a clean
`yarn install && yarn build` works on Apple Silicon (`node-sass` is not used). If you see a
`node-sass`/arm64 error, it's a stale `node_modules` — delete it and reinstall.
- The plugin's messages must be routed to an async transport in the app (see the test app's
`config/packages/dev/setono_sylius_feed.yaml`) so "Generate now" queues a run; process it with
`bin/console messenger:consume main`. (The functional test suite keeps them synchronous.)
- Check the browser console for errors as part of every verification, and prefer
`browser_snapshot` over screenshots for assertions.

### Service Definitions
- **Use the FQCN as the service id.** Register a service under its fully-qualified class name
Expand Down
53 changes: 53 additions & 0 deletions src/Controller/Admin/FeedResultsAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusFeedPlugin\Controller\Admin;

use League\Flysystem\FileAttributes;
use League\Flysystem\FilesystemOperator;
use Setono\SyliusFeedPlugin\Model\FeedInterface;
use Setono\SyliusFeedPlugin\Repository\FeedRepositoryInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Twig\Environment;

/**
* Admin per-context results view (§13): lists the feed's generated files in canonical storage with
* download links to the public route.
*/
final class FeedResultsAction
{
public function __construct(
private readonly FeedRepositoryInterface $feedRepository,
private readonly FilesystemOperator $feedFilesystem,
private readonly Environment $twig,
) {
}

public function __invoke(int|string $id): Response
{
$feed = $this->feedRepository->find($id);
if (!$feed instanceof FeedInterface) {
throw new NotFoundHttpException(sprintf('There is no feed with id "%s"', $id));
}

$files = [];
foreach ($this->feedFilesystem->listContents((string) $feed->getCode(), false) as $item) {
if (!$item instanceof FileAttributes) {
continue;
}

$files[] = [
'filename' => basename($item->path()),
'size' => $item->fileSize(),
'lastModified' => $item->lastModified(),
];
}

return new Response($this->twig->render('@SetonoSyliusFeedPlugin/admin/feed/results.html.twig', [
'feed' => $feed,
'files' => $files,
]));
}
}
47 changes: 47 additions & 0 deletions src/Controller/Admin/GenerateFeedAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusFeedPlugin\Controller\Admin;

use Setono\SyliusFeedPlugin\Message\Command\ProcessFeed;
use Setono\SyliusFeedPlugin\Model\FeedInterface;
use Setono\SyliusFeedPlugin\Repository\FeedRepositoryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\RouterInterface;

/**
* Admin "Generate now" action (§13): dispatches {@see ProcessFeed} for the feed and redirects back
* to the grid with a flash. With an async transport the run is queued; otherwise it runs inline.
*/
final class GenerateFeedAction
{
public function __construct(
private readonly FeedRepositoryInterface $feedRepository,
private readonly MessageBusInterface $commandBus,
private readonly RouterInterface $router,
) {
}

public function __invoke(Request $request, int|string $id): Response
{
$feed = $this->feedRepository->find($id);
if (!$feed instanceof FeedInterface) {
throw new NotFoundHttpException(sprintf('There is no feed with id "%s"', $id));
}

$this->commandBus->dispatch(new ProcessFeed($feed));

$session = $request->getSession();
if ($session instanceof Session) {
$session->getFlashBag()->add('success', 'setono_sylius_feed.feed.generation_dispatched');
}

return new RedirectResponse($this->router->generate('setono_sylius_feed_admin_feed_index'));
}
}
3 changes: 2 additions & 1 deletion src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Setono\SyliusFeedPlugin\DependencyInjection;

use Setono\SyliusFeedPlugin\Form\Type\FeedType;
use Setono\SyliusFeedPlugin\Model\Feed;
use Setono\SyliusFeedPlugin\Model\FeedSource;
use Setono\SyliusFeedPlugin\Model\FeedTranslation;
Expand Down Expand Up @@ -72,7 +73,7 @@ private function addResourcesSection(ArrayNodeDefinition $node): void
->scalarNode('controller')->defaultValue(ResourceController::class)->cannotBeEmpty()->end()
->scalarNode('repository')->defaultValue(FeedRepository::class)->cannotBeEmpty()->end()
->scalarNode('factory')->defaultValue(TranslatableFactory::class)->end()
->scalarNode('form')->defaultValue(DefaultResourceType::class)->cannotBeEmpty()->end()
->scalarNode('form')->defaultValue(FeedType::class)->cannotBeEmpty()->end()
->end()
->end()
->arrayNode('translation')
Expand Down
50 changes: 50 additions & 0 deletions src/Form/Type/FeedFieldType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusFeedPlugin\Form\Type;

use Setono\SyliusFeedPlugin\Mapping\SourceType;
use Setono\SyliusFeedPlugin\Model\FeedField;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
* One output-field mapping (§4.1). M3 supports the `field` and `literal` source types; the
* `expression`/`twig` sources and the transformation/condition sub-editors land in M4.
*/
final class FeedFieldType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('outputField', TextType::class, [
'label' => 'setono_sylius_feed.form.feed_field.output_field',
])
->add('sourceType', ChoiceType::class, [
'label' => 'setono_sylius_feed.form.feed_field.source_type',
'choices' => [
'setono_sylius_feed.form.feed_field.source.field' => SourceType::FIELD->value,
'setono_sylius_feed.form.feed_field.source.literal' => SourceType::LITERAL->value,
],
])
->add('sourceValue', TextType::class, [
'required' => false,
'label' => 'setono_sylius_feed.form.feed_field.source_value',
])
;
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('data_class', FeedField::class);
}

public function getBlockPrefix(): string
{
return 'setono_sylius_feed_feed_field';
}
}
79 changes: 79 additions & 0 deletions src/Form/Type/FeedSourceType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusFeedPlugin\Form\Type;

use Setono\SyliusFeedPlugin\FeedType\FeedTypeRegistryInterface;
use Setono\SyliusFeedPlugin\Model\FeedSource;
use Setono\SyliusFeedPlugin\Model\FeedSourceInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Webmozart\Assert\Assert;

/**
* A feed source: which `feedType` to iterate and its `FeedField` mapping editor (§13). The field
* positions are reindexed on submit so their order in the UI is preserved.
*/
final class FeedSourceType extends AbstractType
{
public function __construct(private readonly FeedTypeRegistryInterface $feedTypeRegistry)
{
}

public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('feedType', ChoiceType::class, [
'label' => 'setono_sylius_feed.form.feed_source.feed_type',
'choices' => $this->feedTypeChoices(),
])
->add('fields', CollectionType::class, [
'label' => 'setono_sylius_feed.form.feed_source.fields',
'entry_type' => FeedFieldType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
])
;

$builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void {
$source = $event->getData();
Assert::isInstanceOf($source, FeedSourceInterface::class);

$position = 0;
foreach ($source->getFields() as $field) {
$field->setPosition($position);
++$position;
}
});
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('data_class', FeedSource::class);
}

public function getBlockPrefix(): string
{
return 'setono_sylius_feed_feed_source';
}

/**
* @return array<string, string>
*/
private function feedTypeChoices(): array
{
$choices = [];
foreach ($this->feedTypeRegistry->all() as $feedType) {
$choices[$feedType->getLabel()] = $feedType->getCode();
}

return $choices;
}
}
30 changes: 30 additions & 0 deletions src/Form/Type/FeedTranslationType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusFeedPlugin\Form\Type;

use Sylius\Bundle\ResourceBundle\Form\Type\AbstractResourceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;

final class FeedTranslationType extends AbstractResourceType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'label' => 'setono_sylius_feed.form.feed.name',
])
->add('slug', TextType::class, [
'required' => false,
'label' => 'setono_sylius_feed.form.feed.slug',
])
;
}

public function getBlockPrefix(): string
{
return 'setono_sylius_feed_feed_translation';
}
}
Loading
Loading