Skip to content

Multi-Market Implementation: Phase 7 - Bean-Centric Buying Options Matrix UI (Optional)

Status: Planning

Dependencies

Requires completed: - Phase 1 (Entities) - BuyingOption entity exists - Phase 2 (Migration) - Database schema ready - Phase 3 (Services) - BuyingOptionService for URL resolution - Phase 6 (EasyAdmin) - CRUD controllers exist as fallback

Blocks: - None (optional enhancement)

📋 Todo Checklist

  • [ ] Create BuyingOptionsMatrixField custom field for EasyAdmin
  • [ ] Add matrix display to CoffeeBeanCrudController detail page
  • [ ] Implement AJAX endpoint for inline URL updates
  • [ ] Add JavaScript for inline editing
  • [ ] Style matrix with CSS
  • [ ] Test inline editing
  • [ ] Test calculated vs manual URLs
  • [ ] Document usage

🔍 Analysis & Investigation

Problem Statement

The standard BuyingOptionCrudController requires admins to: 1. Navigate to BuyingOption CRUD 2. Search for coffee bean 3. Select market 4. Enter URL override 5. Save

This is cumbersome when managing multiple markets for a single bean. A bean-centric matrix UI would be better:

Matrix View (on CoffeeBean detail page):

Market Calculated URL Manual Override Actions
US Direct https://roaster.com/... - [Override]
US Amazon (not available) https://amazon.com/... [Edit] [Remove]
EU https://roaster.eu/... - [Override]
Germany - Roastmarket (not available) - [Add]

Benefits: - See all markets at once - Compare calculated vs manual URLs - Inline editing (no page navigation) - Visual indication of overrides

Current Architecture

CoffeeBeanCrudController: - Standard CRUD operations - Detail page shows bean fields

BuyingOptionCrudController: - Standard CRUD for buying options - No bean-centric view

Target Architecture

Custom EasyAdmin Field: - BuyingOptionsMatrixField - Custom field type - Displays matrix on CoffeeBean detail page - Renders Twig template with interactive table - JavaScript handles inline editing - AJAX endpoint for updates

UX Flow: 1. Admin views bean detail page 2. Sees matrix of all markets with URLs 3. Clicks "Override" or "Edit" to modify 4. Modal/inline form appears 5. Saves via AJAX 6. Matrix updates without page reload

📝 Implementation Plan

Prerequisites

  • Phase 1-6 completed
  • EasyAdmin configured
  • Symfony UX components (optional, for Stimulus)

Step-by-Step Implementation

Step 1: Create AJAX Controller for Buying Option Updates

File: src/Controller/Admin/BuyingOptionAjaxController.php

<?php

namespace App\Controller\Admin;

use App\Entity\BuyingOption;
use App\Entity\CoffeeBean;
use App\Entity\Market;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Validator\ValidatorInterface;

#[Route('/admin/buying-option-ajax', name: 'admin_buying_option_ajax_')]
#[IsGranted('ROLE_ADMIN')]
class BuyingOptionAjaxController extends AbstractController
{
    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly ValidatorInterface $validator
    ) {}

    #[Route('/create', name: 'create', methods: ['POST'])]
    public function create(Request $request): JsonResponse
    {
        $data = json_decode($request->getContent(), true);

        $bean = $this->em->find(CoffeeBean::class, $data['beanId']);
        $market = $this->em->find(Market::class, $data['marketId']);

        if (!$bean || !$market) {
            return new JsonResponse(['error' => 'Bean or market not found'], Response::HTTP_NOT_FOUND);
        }

        // Check if BuyingOption already exists
        $existing = $this->em->getRepository(BuyingOption::class)->findOneBy([
            'coffeeBean' => $bean,
            'market' => $market,
        ]);

        if ($existing) {
            return new JsonResponse(['error' => 'Buying option already exists'], Response::HTTP_CONFLICT);
        }

        $buyingOption = new BuyingOption();
        $buyingOption->setCoffeeBean($bean);
        $buyingOption->setMarket($market);
        $buyingOption->setUrlOverride($data['urlOverride']);
        $buyingOption->setNotes($data['notes'] ?? null);

        // Validate
        $errors = $this->validator->validate($buyingOption);
        if (count($errors) > 0) {
            return new JsonResponse([
                'error' => 'Validation failed',
                'violations' => (string) $errors,
            ], Response::HTTP_BAD_REQUEST);
        }

        $this->em->persist($buyingOption);
        $this->em->flush();

        return new JsonResponse([
            'success' => true,
            'buyingOption' => [
                'id' => $buyingOption->getId(),
                'urlOverride' => $buyingOption->getUrlOverride(),
            ],
        ]);
    }

    #[Route('/update/{id}', name: 'update', methods: ['PUT'])]
    public function update(BuyingOption $buyingOption, Request $request): JsonResponse
    {
        $data = json_decode($request->getContent(), true);

        $buyingOption->setUrlOverride($data['urlOverride']);
        $buyingOption->setNotes($data['notes'] ?? $buyingOption->getNotes());

        // Validate
        $errors = $this->validator->validate($buyingOption);
        if (count($errors) > 0) {
            return new JsonResponse([
                'error' => 'Validation failed',
                'violations' => (string) $errors,
            ], Response::HTTP_BAD_REQUEST);
        }

        $this->em->flush();

        return new JsonResponse([
            'success' => true,
            'buyingOption' => [
                'id' => $buyingOption->getId(),
                'urlOverride' => $buyingOption->getUrlOverride(),
            ],
        ]);
    }

    #[Route('/delete/{id}', name: 'delete', methods: ['DELETE'])]
    public function delete(BuyingOption $buyingOption): JsonResponse
    {
        $this->em->remove($buyingOption);
        $this->em->flush();

        return new JsonResponse(['success' => true]);
    }
}

Step 2: Create Twig Template for Matrix

File: templates/admin/buying_options_matrix.html.twig

<div class="buying-options-matrix" data-bean-id="{{ bean.id }}">
    <h4>Buying Options by Market</h4>
    <p class="text-muted">
        Calculated URLs are generated automatically based on RoasterCrawlConfig priority.
        Manual overrides take precedence over calculated URLs.
    </p>

    <table class="table table-bordered table-hover">
        <thead>
            <tr>
                <th>Market</th>
                <th>Calculated URL</th>
                <th>Manual Override</th>
                <th>Actions</th>
            </tr>
        </thead>
        <tbody>
            {% for market in markets %}
                {% set calculatedUrl = calculated_urls[market.id] ?? null %}
                {% set buyingOption = buying_options[market.id] ?? null %}
                <tr data-market-id="{{ market.id }}">
                    <td>
                        <strong>{{ market.name }}</strong>
                        {% if not market.isActive %}
                            <span class="badge badge-secondary">Inactive</span>
                        {% endif %}
                    </td>
                    <td class="calculated-url">
                        {% if calculatedUrl %}
                            <a href="{{ calculatedUrl }}" target="_blank" class="text-truncate d-inline-block" style="max-width: 300px;">
                                {{ calculatedUrl }}
                            </a>
                        {% else %}
                            <span class="text-muted">(not available)</span>
                        {% endif %}
                    </td>
                    <td class="manual-override">
                        {% if buyingOption %}
                            <a href="{{ buyingOption.urlOverride }}" target="_blank" class="text-truncate d-inline-block" style="max-width: 300px;">
                                {{ buyingOption.urlOverride }}
                            </a>
                            <span class="badge badge-warning">Override</span>
                        {% else %}
                            <span class="text-muted">-</span>
                        {% endif %}
                    </td>
                    <td class="actions">
                        {% if buyingOption %}
                            <button class="btn btn-sm btn-primary edit-override" data-buying-option-id="{{ buyingOption.id }}">
                                Edit
                            </button>
                            <button class="btn btn-sm btn-danger remove-override" data-buying-option-id="{{ buyingOption.id }}">
                                Remove
                            </button>
                        {% else %}
                            <button class="btn btn-sm btn-success add-override" data-market-id="{{ market.id }}">
                                Add Override
                            </button>
                        {% endif %}
                    </td>
                </tr>
            {% endfor %}
        </tbody>
    </table>
</div>

{# Modal for editing #}
<div class="modal fade" id="buyingOptionModal" tabindex="-1">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">Edit Buying Option</h5>
                <button type="button" class="close" data-dismiss="modal">
                    <span>&times;</span>
                </button>
            </div>
            <div class="modal-body">
                <form id="buyingOptionForm">
                    <div class="form-group">
                        <label>Market</label>
                        <input type="text" class="form-control" id="marketName" readonly>
                    </div>
                    <div class="form-group">
                        <label>URL Override *</label>
                        <input type="url" class="form-control" id="urlOverride" required>
                        <small class="form-text text-muted">Full product URL (e.g., https://amazon.de/dp/B08XYZ)</small>
                    </div>
                    <div class="form-group">
                        <label>Notes</label>
                        <textarea class="form-control" id="notes" rows="3"></textarea>
                    </div>
                </form>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
                <button type="button" class="btn btn-primary" id="saveBuyingOption">Save</button>
            </div>
        </div>
    </div>
</div>

Step 3: Create JavaScript for Inline Editing

File: assets/admin/buying-options-matrix.js

document.addEventListener('DOMContentLoaded', function() {
    const matrix = document.querySelector('.buying-options-matrix');
    if (!matrix) return;

    const beanId = matrix.dataset.beanId;
    const modal = new bootstrap.Modal(document.getElementById('buyingOptionModal'));
    let currentAction = null;
    let currentMarketId = null;
    let currentBuyingOptionId = null;

    // Add override button
    matrix.querySelectorAll('.add-override').forEach(btn => {
        btn.addEventListener('click', function() {
            currentAction = 'create';
            currentMarketId = this.dataset.marketId;
            currentBuyingOptionId = null;

            const row = this.closest('tr');
            const marketName = row.querySelector('td:first-child strong').textContent;

            document.getElementById('marketName').value = marketName;
            document.getElementById('urlOverride').value = '';
            document.getElementById('notes').value = '';

            modal.show();
        });
    });

    // Edit override button
    matrix.querySelectorAll('.edit-override').forEach(btn => {
        btn.addEventListener('click', function() {
            currentAction = 'update';
            currentBuyingOptionId = this.dataset.buyingOptionId;

            const row = this.closest('tr');
            const marketName = row.querySelector('td:first-child strong').textContent;
            const currentUrl = row.querySelector('.manual-override a').textContent.trim();

            document.getElementById('marketName').value = marketName;
            document.getElementById('urlOverride').value = currentUrl;
            document.getElementById('notes').value = '';

            modal.show();
        });
    });

    // Remove override button
    matrix.querySelectorAll('.remove-override').forEach(btn => {
        btn.addEventListener('click', function() {
            if (!confirm('Remove this manual override? The calculated URL will be used instead.')) {
                return;
            }

            const buyingOptionId = this.dataset.buyingOptionId;

            fetch(`/admin/buying-option-ajax/delete/${buyingOptionId}`, {
                method: 'DELETE',
                headers: {
                    'Content-Type': 'application/json',
                }
            })
            .then(response => response.json())
            .then(data => {
                if (data.success) {
                    // Reload page to refresh matrix
                    location.reload();
                } else {
                    alert('Error: ' + (data.error || 'Unknown error'));
                }
            })
            .catch(error => {
                alert('Network error: ' + error.message);
            });
        });
    });

    // Save button in modal
    document.getElementById('saveBuyingOption').addEventListener('click', function() {
        const urlOverride = document.getElementById('urlOverride').value;
        const notes = document.getElementById('notes').value;

        if (!urlOverride) {
            alert('URL Override is required');
            return;
        }

        let url, method, body;

        if (currentAction === 'create') {
            url = '/admin/buying-option-ajax/create';
            method = 'POST';
            body = JSON.stringify({
                beanId: beanId,
                marketId: currentMarketId,
                urlOverride: urlOverride,
                notes: notes,
            });
        } else if (currentAction === 'update') {
            url = `/admin/buying-option-ajax/update/${currentBuyingOptionId}`;
            method = 'PUT';
            body = JSON.stringify({
                urlOverride: urlOverride,
                notes: notes,
            });
        }

        fetch(url, {
            method: method,
            headers: {
                'Content-Type': 'application/json',
            },
            body: body,
        })
        .then(response => response.json())
        .then(data => {
            if (data.success) {
                modal.hide();
                // Reload page to refresh matrix
                location.reload();
            } else {
                alert('Error: ' + (data.error || 'Unknown error'));
            }
        })
        .catch(error => {
            alert('Network error: ' + error.message);
        });
    });
});

Step 4: Create Custom EasyAdmin Field

File: src/Field/BuyingOptionsMatrixField.php

<?php

namespace App\Field;

use App\Entity\CoffeeBean;
use App\Entity\Country;
use App\Repository\BuyingOptionRepository;
use App\Repository\MarketRepository;
use App\Service\Market\BuyingOptionService;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface;
use EasyCorp\Bundle\EasyAdminBundle\Field\FieldTrait;

final class BuyingOptionsMatrixField implements FieldInterface
{
    use FieldTrait;

    public static function new(string $propertyName, ?string $label = null): self
    {
        return (new self())
            ->setProperty($propertyName)
            ->setLabel($label ?? 'Buying Options by Market')
            ->setTemplatePath('admin/buying_options_matrix.html.twig')
            ->setFormattedValue(function ($value, CoffeeBean $bean) {
                // This will be called when rendering the field
                // We'll prepare the data needed for the template
                return null; // Data is prepared in configureAssets
            });
    }

    /**
     * Prepare template variables.
     */
    public function configureAssets(): self
    {
        return $this->setCustomOption('assets', [
            'js' => ['admin/buying-options-matrix.js'],
            'css' => ['admin/buying-options-matrix.css'],
        ]);
    }
}

Note: The custom field needs access to services. This may require a more complex implementation using EasyAdmin's field configurator pattern. Alternative approach: Use a standard TextField with custom formatter.

Step 5: Add Matrix to CoffeeBeanCrudController

File: src/Controller/Admin/CoffeeBeanCrudController.php (modify existing)

Add custom section to detail page:

use App\Repository\MarketRepository;
use App\Repository\BuyingOptionRepository;
use App\Service\Market\BuyingOptionService;
use EasyCorp\Bundle\EasyAdminBundle\Field\FormField;

public function configureFields(string $pageName): iterable
{
    // ... existing fields ...

    // Add buying options matrix on detail page only
    if ($pageName === Crud::PAGE_DETAIL) {
        yield FormField::addFieldset('Buying Options by Market')
            ->setHelp('Configure where this bean can be purchased in different markets');

        // Custom template render
        yield FormField::addPanel('buying_options_matrix')
            ->setTemplatePath('admin/buying_options_matrix.html.twig')
            ->onlyOnDetail();
    }
}

/**
 * Override detail action to inject matrix data.
 */
public function detail(AdminContext $context)
{
    $bean = $context->getEntity()->getInstance();

    // Fetch all markets
    $markets = $this->container->get(MarketRepository::class)->findAll();

    // Calculate URLs for each market
    $calculatedUrls = [];
    foreach ($markets as $market) {
        if ($market->isActive() && $market->getCountries()->count() > 0) {
            $firstCountry = $market->getCountries()->first();
            $url = $this->container->get(BuyingOptionService::class)
                ->getUrlForVisitor($bean, $firstCountry);
            if ($url) {
                $calculatedUrls[$market->getId()] = $url;
            }
        }
    }

    // Fetch existing buying options
    $buyingOptions = [];
    foreach ($bean->getBuyingOptions() as $option) {
        $buyingOptions[$option->getMarket()->getId()] = $option;
    }

    // Inject into template variables
    $context->getAssets()->addWebpackEncoreEntry('admin-buying-options-matrix');

    $response = parent::detail($context);

    // Add custom variables to template
    $response->setData(array_merge($response->getData(), [
        'markets' => $markets,
        'calculated_urls' => $calculatedUrls,
        'buying_options' => $buyingOptions,
        'bean' => $bean,
    ]));

    return $response;
}

Note: This is a simplified approach. A more robust implementation would use EasyAdmin's event system or custom field rendering.

Testing Strategy

Manual Testing

  1. Matrix Display:
  2. View bean detail page
  3. Verify matrix shows all markets
  4. Verify calculated URLs appear
  5. Verify manual overrides appear with badge

  6. Inline Editing:

  7. Click "Add Override"
  8. Enter URL in modal
  9. Save and verify update
  10. Click "Edit" on existing override
  11. Modify URL and save
  12. Click "Remove" and confirm deletion

  13. Validation:

  14. Try invalid URL (should reject)
  15. Try duplicate bean-market combination
  16. Verify error messages

  17. UI/UX:

  18. Verify responsive layout
  19. Test modal interactions
  20. Test with many markets (scrolling)

Integration Testing

File: tests/Controller/Admin/BuyingOptionAjaxControllerTest.php

Test AJAX endpoints:

public function testCreateBuyingOption(): void
{
    $response = $this->client->request('POST', '/admin/buying-option-ajax/create', [
        'json' => [
            'beanId' => 'bean-id',
            'marketId' => 'market-id',
            'urlOverride' => 'https://amazon.de/dp/B08XYZ',
        ],
    ]);

    $this->assertResponseIsSuccessful();
    $data = $response->toArray();
    $this->assertTrue($data['success']);
}

public function testUpdateBuyingOption(): void
{
    // ... test PUT endpoint ...
}

public function testDeleteBuyingOption(): void
{
    // ... test DELETE endpoint ...
}

🎯 Success Criteria

  • Matrix UI displays on CoffeeBean detail page
  • Shows all markets with calculated and manual URLs
  • Inline editing works via modal
  • AJAX endpoints handle create/update/delete
  • Validation prevents invalid data
  • UI is responsive and user-friendly
  • No page reloads needed (AJAX updates)
  • Fallback to BuyingOptionCrudController still works
  • All tests pass

Notes

This phase is optional but provides significant UX improvement for admins managing buying options. The standard BuyingOptionCrudController (Phase 6) provides all functionality; this phase just makes it more convenient.

Implementation Complexity: - Moderate (custom field rendering in EasyAdmin) - Requires JavaScript for inline editing - AJAX endpoints for updates

Alternative Approach: Instead of custom EasyAdmin field, could use: - Separate admin page (not in EasyAdmin) - Symfony UX Live Components for reactivity - Full SPA (React/Vue) for matrix management

Files to Create:

  • src/Controller/Admin/BuyingOptionAjaxController.php
  • templates/admin/buying_options_matrix.html.twig
  • assets/admin/buying-options-matrix.js
  • assets/admin/buying-options-matrix.css
  • src/Field/BuyingOptionsMatrixField.php (optional)
  • tests/Controller/Admin/BuyingOptionAjaxControllerTest.php

Files to Modify:

  • src/Controller/Admin/CoffeeBeanCrudController.php

Files Referenced:

  • src/Entity/CoffeeBean.php
  • src/Entity/Market.php
  • src/Entity/BuyingOption.php
  • src/Service/Market/BuyingOptionService.php
  • src/Repository/MarketRepository.php
  • src/Repository/BuyingOptionRepository.php

Next Steps

After completing this phase (or skipping if not needed): - Proceed to Phase 8 (Cleanup) to remove CoffeeBean.available field - Multi-market system fully functional with optimal UX