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>×</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¶
- Matrix Display:
- View bean detail page
- Verify matrix shows all markets
- Verify calculated URLs appear
-
Verify manual overrides appear with badge
-
Inline Editing:
- Click "Add Override"
- Enter URL in modal
- Save and verify update
- Click "Edit" on existing override
- Modify URL and save
-
Click "Remove" and confirm deletion
-
Validation:
- Try invalid URL (should reject)
- Try duplicate bean-market combination
-
Verify error messages
-
UI/UX:
- Verify responsive layout
- Test modal interactions
- 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
Related Files¶
Files to Create:¶
src/Controller/Admin/BuyingOptionAjaxController.phptemplates/admin/buying_options_matrix.html.twigassets/admin/buying-options-matrix.jsassets/admin/buying-options-matrix.csssrc/Field/BuyingOptionsMatrixField.php(optional)tests/Controller/Admin/BuyingOptionAjaxControllerTest.php
Files to Modify:¶
src/Controller/Admin/CoffeeBeanCrudController.php
Files Referenced:¶
src/Entity/CoffeeBean.phpsrc/Entity/Market.phpsrc/Entity/BuyingOption.phpsrc/Service/Market/BuyingOptionService.phpsrc/Repository/MarketRepository.phpsrc/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