feat(catalogue): options de slot de menu filtrees par type (slot_type vers categorie) + garde serveur
All checks were successful
CI / secret-scan (push) Successful in 22s
CI / php-lint (push) Successful in 33s
CI / static-tests (push) Successful in 1m19s
CI / js-tests (push) Successful in 49s
CI / secret-scan (pull_request) Successful in 29s
CI / php-lint (pull_request) Successful in 40s
CI / static-tests (pull_request) Successful in 1m31s
CI / js-tests (pull_request) Successful in 55s
All checks were successful
CI / secret-scan (push) Successful in 22s
CI / php-lint (push) Successful in 33s
CI / static-tests (push) Successful in 1m19s
CI / js-tests (push) Successful in 49s
CI / secret-scan (pull_request) Successful in 29s
CI / php-lint (pull_request) Successful in 40s
CI / static-tests (pull_request) Successful in 1m31s
CI / js-tests (pull_request) Successful in 55s
This commit is contained in:
parent
be4585aeb2
commit
774f88a06f
8 changed files with 534 additions and 133 deletions
|
|
@ -164,6 +164,27 @@ final class MenuRepository
|
|||
) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slug de categorie d'un produit, ou null si l'id est inconnu. Garde serveur F12 :
|
||||
* une option de slot doit appartenir a une categorie autorisee pour le slot_type
|
||||
* du slot (mapping unique cote MenuController). Le controleur croise ce slug avec
|
||||
* la liste autorisee et rejette (422) une option hors categorie meme si l'UI de
|
||||
* filtrage est contournee -- defense en profondeur (RG-T18), par-dessus la garde
|
||||
* base-only existante (productIsBase, F9).
|
||||
*/
|
||||
public function productCategorySlug(int $id): ?string
|
||||
{
|
||||
$row = $this->db->fetch(
|
||||
'SELECT c.slug AS category_slug FROM product p '
|
||||
. 'JOIN category c ON c.id = p.category_id WHERE p.id = :id',
|
||||
['id' => $id],
|
||||
);
|
||||
|
||||
$slug = $row['category_slug'] ?? null;
|
||||
|
||||
return is_string($slug) ? $slug : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-verification FK-safe (mlt 8.6 RG-1) : le menu est-il reference par une
|
||||
* ligne de commande historique ? La FK order_item.menu_id est RESTRICT.
|
||||
|
|
|
|||
|
|
@ -73,6 +73,29 @@ final class ProductRepository
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produits de BASE (base_product_id IS NULL, R4) avec le slug de leur CATEGORIE,
|
||||
* pour alimenter les OPTIONS de slot du formulaire menu (F12). Le formulaire doit
|
||||
* filtrer les options proposees selon le type de slot (drink -> boissons, etc.) ;
|
||||
* il lui faut donc la categorie de chaque produit, que basesOnly() (projection
|
||||
* stricte {id, name}) ne porte pas. Methode dediee plutot qu'extension de
|
||||
* basesOnly() : ce dernier alimente aussi le select base_product_id du formulaire
|
||||
* produit (ProductController), qui n'a pas besoin de la categorie -- garder son
|
||||
* contrat minimal evite un couplage inutile. Meme predicat anti-variante que
|
||||
* basesOnly(), miroir de la garde serveur MenuRepository::productIsBase().
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function baseOptionsWithCategory(): array
|
||||
{
|
||||
return $this->db->fetchAll(
|
||||
'SELECT p.id, p.name, c.slug AS category_slug '
|
||||
. 'FROM product p JOIN category c ON c.id = p.category_id '
|
||||
. 'WHERE p.base_product_id IS NULL '
|
||||
. 'ORDER BY p.display_order, p.name',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -36,6 +36,32 @@ class MenuController extends AdminController
|
|||
{
|
||||
private const SLOT_TYPES = ['drink', 'side', 'sauce', 'dessert', 'extra'];
|
||||
|
||||
/**
|
||||
* F12 : SOURCE UNIQUE du mapping slot_type -> categorie(s) eligibles (slugs FR du
|
||||
* seed 0002). Une option de slot ne peut etre qu'un produit dont la categorie est
|
||||
* dans la liste de son slot_type. Ce meme tableau est (a) passe a la vue puis au
|
||||
* builder JS (filtrage UI dynamique), et (b) reutilise par la garde serveur
|
||||
* parseSlots() (rejet 422 d'une option hors categorie) -- pas de double definition
|
||||
* divergente.
|
||||
*
|
||||
* Regle metier (decidee avec l'utilisateur) :
|
||||
* - drink/sauce/dessert : leur categorie homonyme ;
|
||||
* - side : les accompagnements (frites + encas + salades) ;
|
||||
* - extra : slot LIBRE = toute categorie SAUF 'menus' (pas de menu dans un menu)
|
||||
* et 'burgers' (le burger est l'ancre du menu, champ separe, jamais une option).
|
||||
* 'menus' n'apparait dans aucune liste : un menu ne se compose pas d'un autre menu.
|
||||
* Le slug 'wraps' n'est eligible que via 'extra' (slot libre).
|
||||
*
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
private const SLOT_CATEGORIES = [
|
||||
'drink' => ['boissons'],
|
||||
'sauce' => ['sauces'],
|
||||
'dessert' => ['desserts'],
|
||||
'side' => ['frites', 'encas', 'salades'],
|
||||
'extra' => ['boissons', 'frites', 'encas', 'wraps', 'salades', 'desserts', 'sauces'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
|
|
@ -388,13 +414,22 @@ class MenuController extends AdminController
|
|||
$slotType = is_string($raw['slot_type'] ?? null) ? $raw['slot_type'] : '';
|
||||
$required = !empty($raw['is_required']) ? 1 : 0;
|
||||
|
||||
// Categories autorisees pour ce slot_type (F12, mapping unique). Tableau
|
||||
// vide pour un slot_type inconnu : aucune option n'y est alors eligible,
|
||||
// mais le type invalide est rejete plus bas avant d'utiliser ce resultat.
|
||||
$allowedCategories = self::SLOT_CATEGORIES[$slotType] ?? [];
|
||||
|
||||
// F9-2 : une option de slot doit etre un produit de BASE (R4). Un id de
|
||||
// variante de taille (base_product_id non nul) est REJETE explicitement
|
||||
// (422) plutot que filtre en silence : choisir une variante comme option
|
||||
// serait un contournement de l'UI base-only, et un drop muet ferait perdre
|
||||
// un choix sans message clair. Un id inconnu reste filtre (allowlist).
|
||||
// F12 : par-dessus, l'option doit appartenir a une categorie autorisee pour
|
||||
// le slot_type ; une option hors categorie est REJETEE (422), pas filtree,
|
||||
// pour la meme raison (contournement de l'UI de filtrage = erreur visible).
|
||||
$optionIds = [];
|
||||
$hasVariantOption = false;
|
||||
$hasWrongCategoryOption = false;
|
||||
foreach (is_array($raw['options'] ?? null) ? $raw['options'] : [] as $opt) {
|
||||
$pid = is_numeric($opt) ? (int) $opt : 0;
|
||||
if ($pid <= 0 || !$this->menuRepository()->productExists($pid)) {
|
||||
|
|
@ -404,6 +439,11 @@ class MenuController extends AdminController
|
|||
$hasVariantOption = true;
|
||||
continue; // variante de taille : non eligible comme option de menu
|
||||
}
|
||||
$categorySlug = $this->menuRepository()->productCategorySlug($pid);
|
||||
if ($categorySlug === null || !in_array($categorySlug, $allowedCategories, true)) {
|
||||
$hasWrongCategoryOption = true;
|
||||
continue; // hors categorie pour ce slot_type : non eligible
|
||||
}
|
||||
$optionIds[] = $pid;
|
||||
}
|
||||
$optionIds = array_values(array_unique($optionIds));
|
||||
|
|
@ -420,6 +460,10 @@ class MenuController extends AdminController
|
|||
$errors['slots'] = 'Une variante de taille ne peut pas etre proposee comme option de menu (choisissez le produit de base).';
|
||||
continue;
|
||||
}
|
||||
if ($hasWrongCategoryOption) {
|
||||
$errors['slots'] = 'Une option proposee n\'appartient pas a une categorie compatible avec le type de ce slot.';
|
||||
continue;
|
||||
}
|
||||
if ($optionIds === []) {
|
||||
$errors['slots'] = 'Chaque slot doit proposer au moins une option valide.';
|
||||
continue;
|
||||
|
|
@ -479,9 +523,16 @@ class MenuController extends AdminController
|
|||
'categories' => $this->categoryRepository()->all(),
|
||||
// F9-1 : listes deroulantes base-only (burger principal + options de
|
||||
// slot). basesOnly() exclut les variantes de taille (R4) ; all() les
|
||||
// inclut (liste admin), il ne doit donc pas alimenter ces selects.
|
||||
// inclut (liste admin), il ne doit donc pas alimenter ces selects. Le
|
||||
// burger principal (select dedie) consomme cette liste {id, name}.
|
||||
'products' => $this->productRepository()->basesOnly(),
|
||||
// F12 : options de slot, base-only ENRICHIES du slug de categorie, pour
|
||||
// que le builder JS filtre les choix proposes selon le type de slot.
|
||||
'slotProducts' => $this->productRepository()->baseOptionsWithCategory(),
|
||||
'slotTypes' => self::SLOT_TYPES,
|
||||
// F12 : mapping slot_type -> categories, source unique partagee avec la
|
||||
// garde serveur (self::SLOT_CATEGORIES) et exposee au builder JS.
|
||||
'slotCategories' => self::SLOT_CATEGORIES,
|
||||
'values' => [
|
||||
'category_id' => (string) ($values['category_id'] ?? ''),
|
||||
'burger_product_id' => (string) ($values['burger_product_id'] ?? ''),
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ declare(strict_types=1);
|
|||
*
|
||||
* @var int $menuId
|
||||
* @var array<int, array<string, mixed>> $categories
|
||||
* @var array<int, array<string, mixed>> $products
|
||||
* @var array<int, array<string, mixed>> $products burgers de base (select ancre)
|
||||
* @var array<int, array<string, mixed>> $slotProducts options de slot {id, name, category_slug} (F12)
|
||||
* @var list<string> $slotTypes
|
||||
* @var array<string, list<string>> $slotCategories slot_type -> categories autorisees (F12)
|
||||
* @var array<string, mixed> $values
|
||||
* @var string $slotsJson
|
||||
* @var array<string, string> $errors
|
||||
|
|
@ -30,8 +32,12 @@ $errs = isset($errors) && is_array($errors) ? $errors : [];
|
|||
$cats = isset($categories) && is_array($categories) ? $categories : [];
|
||||
/** @var array<int, array<string, mixed>> $prods */
|
||||
$prods = isset($products) && is_array($products) ? $products : [];
|
||||
/** @var array<int, array<string, mixed>> $slotProds */
|
||||
$slotProds = isset($slotProducts) && is_array($slotProducts) ? $slotProducts : [];
|
||||
/** @var list<string> $types */
|
||||
$types = isset($slotTypes) && is_array($slotTypes) ? $slotTypes : [];
|
||||
/** @var array<string, list<string>> $slotCats */
|
||||
$slotCats = isset($slotCategories) && is_array($slotCategories) ? $slotCategories : [];
|
||||
|
||||
$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : '';
|
||||
|
|
@ -41,9 +47,16 @@ $available = (bool) ($vals['is_available'] ?? true);
|
|||
|
||||
// Donnees pour le builder JS, passees en attributs data-* (CSP 'self' : pas de
|
||||
// script inline). htmlspecialchars rend le JSON sur-able comme valeur d'attribut.
|
||||
// F12 : chaque option de slot porte sa categorie (category) pour que le builder
|
||||
// filtre les choix proposes selon le type de slot ; le mapping slot_type ->
|
||||
// categories (slotCategories) provient de la meme source que la garde serveur.
|
||||
$slimProducts = array_map(
|
||||
static fn (array $p): array => ['id' => (int) ($p['id'] ?? 0), 'name' => (string) ($p['name'] ?? '')],
|
||||
$prods,
|
||||
static fn (array $p): array => [
|
||||
'id' => (int) ($p['id'] ?? 0),
|
||||
'name' => (string) ($p['name'] ?? ''),
|
||||
'category' => (string) ($p['category_slug'] ?? ''),
|
||||
],
|
||||
$slotProds,
|
||||
);
|
||||
$attr = static fn (mixed $data): string => htmlspecialchars(
|
||||
(string) json_encode($data, JSON_UNESCAPED_UNICODE),
|
||||
|
|
@ -124,6 +137,7 @@ $slotsData = isset($slotsJson) && is_string($slotsJson) && $slotsJson !== '' ? $
|
|||
<div id="slot-builder"
|
||||
data-products="<?= $attr($slimProducts) ?>"
|
||||
data-slot-types="<?= $attr($types) ?>"
|
||||
data-slot-categories="<?= $attr($slotCats) ?>"
|
||||
data-slots="<?= htmlspecialchars($slotsData, ENT_QUOTES, 'UTF-8') ?>"></div>
|
||||
<button class="btn btn-secondary" type="button" id="add-slot">Ajouter un slot</button>
|
||||
</fieldset>
|
||||
|
|
|
|||
|
|
@ -2,23 +2,34 @@
|
|||
* menu-form.js — Builder de slots du formulaire menu (back-office).
|
||||
*
|
||||
* CSP 'self' : script externe (pas d'inline). Les donnees (produits, types,
|
||||
* slots initiaux) sont lues depuis les attributs data-* de #slot-builder.
|
||||
* A la soumission, l'etat des slots est serialise en JSON dans le champ cache
|
||||
* #slots_json (Request::formBody cote serveur ne garde que les scalaires, d'ou
|
||||
* le passage par une chaine JSON). Le serveur revalide tout (RG-T18).
|
||||
* mapping slot_type -> categories, slots initiaux) sont lues depuis les attributs
|
||||
* data-* de #slot-builder. A la soumission, l'etat des slots est serialise en JSON
|
||||
* dans le champ cache #slots_json (Request::formBody cote serveur ne garde que les
|
||||
* scalaires, d'ou le passage par une chaine JSON). Le serveur revalide tout (RG-T18).
|
||||
*
|
||||
* F12 : les options proposees dans un slot sont FILTREES par le type de slot. Chaque
|
||||
* produit porte sa categorie (data-products[].category) ; le mapping slot_type ->
|
||||
* categories (data-slot-categories) decide quelles categories sont eligibles. Le type
|
||||
* etant un <select> modifiable, la liste d'options est RE-RENDUE a chaque changement
|
||||
* de type. Le mapping vient de MenuController::SLOT_CATEGORIES (source unique, aussi
|
||||
* appliquee par la garde serveur parseSlots).
|
||||
*
|
||||
* Module CommonJS (admin = racine CommonJS, comme pin-modal.js / counter-order.js) :
|
||||
* init(doc) est exporte pour les tests et auto-appele au DOMContentLoaded en prod.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var builder = document.getElementById('slot-builder');
|
||||
var form = document.getElementById('menu-form');
|
||||
var hidden = document.getElementById('slots_json');
|
||||
var addBtn = document.getElementById('add-slot');
|
||||
if (!builder || !form || !hidden || !addBtn) {
|
||||
return;
|
||||
function el(doc, tag, className) {
|
||||
var e = doc.createElement(tag);
|
||||
if (className) {
|
||||
e.className = className;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
function parseData(key, fallback) {
|
||||
// Lit un attribut data-* JSON et retourne un Array, sinon le fallback (forme tableau).
|
||||
function parseArray(builder, key, fallback) {
|
||||
try {
|
||||
var v = JSON.parse(builder.dataset[key] || fallback);
|
||||
return Array.isArray(v) ? v : JSON.parse(fallback);
|
||||
|
|
@ -27,34 +38,99 @@
|
|||
}
|
||||
}
|
||||
|
||||
var products = parseData('products', '[]'); // [{id, name}]
|
||||
var slotTypes = parseData('slotTypes', '[]'); // ['drink', 'side', ...]
|
||||
var initialSlots = parseData('slots', '[]'); // [{name, slot_type, is_required, options:[id]}]
|
||||
|
||||
function el(tag, className) {
|
||||
var e = document.createElement(tag);
|
||||
if (className) {
|
||||
e.className = className;
|
||||
// Lit un attribut data-* JSON et retourne un objet simple (le mapping slot_type ->
|
||||
// categories), sinon {} ; tolerant aux entrees non-objet / mal formees.
|
||||
function parseObject(builder, key) {
|
||||
try {
|
||||
var v = JSON.parse(builder.dataset[key] || '{}');
|
||||
return (v && typeof v === 'object' && !Array.isArray(v)) ? v : {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Categories eligibles pour un slot_type donne (liste, vide si type inconnu).
|
||||
function allowedCategories(slotCategories, slotType) {
|
||||
var list = slotCategories[slotType];
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
// Un produit est-il proposable dans un slot de ce type ? (sa categorie figure dans
|
||||
// la liste autorisee). Source unique de la decision UI, miroir de la garde serveur.
|
||||
function productAllowed(product, slotCategories, slotType) {
|
||||
return allowedCategories(slotCategories, slotType).indexOf(String(product.category)) !== -1;
|
||||
}
|
||||
|
||||
function init(doc) {
|
||||
var builder = doc.getElementById('slot-builder');
|
||||
var form = doc.getElementById('menu-form');
|
||||
var hidden = doc.getElementById('slots_json');
|
||||
var addBtn = doc.getElementById('add-slot');
|
||||
if (!builder || !form || !hidden || !addBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
var products = parseArray(builder, 'products', '[]'); // [{id, name, category}]
|
||||
var slotTypes = parseArray(builder, 'slotTypes', '[]'); // ['drink', 'side', ...]
|
||||
var slotCategories = parseObject(builder, 'slotCategories'); // {drink:['boissons'], ...}
|
||||
var initialSlots = parseArray(builder, 'slots', '[]'); // [{name, slot_type, is_required, options:[id]}]
|
||||
|
||||
// (Re)construit la liste des cases a cocher d'options pour un type de slot donne.
|
||||
// N'affiche QUE les produits dont la categorie est eligible (F12). Les ids deja
|
||||
// coches qui restent eligibles sont conserves coches ; un id devenu non eligible
|
||||
// (changement de type) DISPARAIT simplement de la liste : c'est le comportement
|
||||
// le plus previsible pour un equipier non technicien (une option absente ne peut
|
||||
// pas etre soumise par megarde), et la garde serveur rejetterait de toute facon
|
||||
// une option hors categorie. selectedSet : set d'ids coches a preserver.
|
||||
function renderOptions(optWrap, slotType, selectedSet) {
|
||||
optWrap.textContent = '';
|
||||
var shown = 0;
|
||||
products.forEach(function (p) {
|
||||
if (!productAllowed(p, slotCategories, slotType)) {
|
||||
return;
|
||||
}
|
||||
shown += 1;
|
||||
var lab = el(doc, 'label');
|
||||
lab.style.display = 'block';
|
||||
var cb = el(doc, 'input', 'slot-option');
|
||||
cb.type = 'checkbox';
|
||||
cb.value = String(p.id);
|
||||
if (selectedSet[String(p.id)]) {
|
||||
cb.checked = true;
|
||||
}
|
||||
lab.appendChild(cb);
|
||||
lab.appendChild(doc.createTextNode(' ' + String(p.name)));
|
||||
optWrap.appendChild(lab);
|
||||
});
|
||||
// Repere visible quand aucun produit n'est eligible (ex. type sans catalogue) :
|
||||
// evite une zone vide muette pour l'equipier.
|
||||
if (shown === 0) {
|
||||
var empty = el(doc, 'p');
|
||||
empty.className = 'slot-options-empty';
|
||||
empty.textContent = 'Aucun produit disponible pour ce type de slot.';
|
||||
optWrap.appendChild(empty);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
// Construit le bloc DOM d'un slot. `slot` peut etre vide (creation).
|
||||
function renderSlot(slot) {
|
||||
slot = slot || {};
|
||||
var selectedOptions = Array.isArray(slot.options) ? slot.options.map(Number) : [];
|
||||
var selectedSet = {};
|
||||
(Array.isArray(slot.options) ? slot.options : []).forEach(function (id) {
|
||||
selectedSet[String(Number(id))] = true;
|
||||
});
|
||||
|
||||
var block = el('fieldset', 'slot-block form-group');
|
||||
var block = el(doc, 'fieldset', 'slot-block form-group');
|
||||
block.style.border = '1px solid #ddd';
|
||||
block.style.padding = '0.75rem';
|
||||
block.style.marginBottom = '0.75rem';
|
||||
|
||||
var head = el('div');
|
||||
var head = el(doc, 'div');
|
||||
|
||||
// Nom du slot
|
||||
var nameLabel = el('label');
|
||||
nameLabel.appendChild(document.createTextNode('Nom du slot '));
|
||||
var nameInput = el('input', 'form-input slot-name');
|
||||
var nameLabel = el(doc, 'label');
|
||||
nameLabel.appendChild(doc.createTextNode('Nom du slot '));
|
||||
var nameInput = el(doc, 'input', 'form-input slot-name');
|
||||
nameInput.type = 'text';
|
||||
nameInput.maxLength = 80;
|
||||
nameInput.value = slot.name ? String(slot.name) : '';
|
||||
|
|
@ -62,11 +138,11 @@
|
|||
head.appendChild(nameLabel);
|
||||
|
||||
// Type
|
||||
var typeLabel = el('label');
|
||||
typeLabel.appendChild(document.createTextNode(' Type '));
|
||||
var typeSelect = el('select', 'form-input slot-type');
|
||||
var typeLabel = el(doc, 'label');
|
||||
typeLabel.appendChild(doc.createTextNode(' Type '));
|
||||
var typeSelect = el(doc, 'select', 'form-input slot-type');
|
||||
slotTypes.forEach(function (t) {
|
||||
var opt = el('option');
|
||||
var opt = el(doc, 'option');
|
||||
opt.value = String(t);
|
||||
opt.textContent = String(t);
|
||||
if (String(slot.slot_type) === String(t)) {
|
||||
|
|
@ -78,18 +154,18 @@
|
|||
head.appendChild(typeLabel);
|
||||
|
||||
// Requis
|
||||
var reqLabel = el('label');
|
||||
var reqInput = el('input', 'slot-required');
|
||||
var reqLabel = el(doc, 'label');
|
||||
var reqInput = el(doc, 'input', 'slot-required');
|
||||
reqInput.type = 'checkbox';
|
||||
if (Number(slot.is_required) === 1) {
|
||||
reqInput.checked = true;
|
||||
}
|
||||
reqLabel.appendChild(reqInput);
|
||||
reqLabel.appendChild(document.createTextNode(' Requis'));
|
||||
reqLabel.appendChild(doc.createTextNode(' Requis'));
|
||||
head.appendChild(reqLabel);
|
||||
|
||||
// Retirer
|
||||
var removeBtn = el('button', 'btn btn-secondary slot-remove');
|
||||
var removeBtn = el(doc, 'button', 'btn btn-secondary slot-remove');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.textContent = 'Retirer';
|
||||
removeBtn.addEventListener('click', function () {
|
||||
|
|
@ -99,26 +175,30 @@
|
|||
|
||||
block.appendChild(head);
|
||||
|
||||
// Options : cases a cocher des produits eligibles
|
||||
var optWrap = el('div', 'slot-options');
|
||||
// Options : cases a cocher des produits eligibles AU TYPE COURANT (F12).
|
||||
var optWrap = el(doc, 'div', 'slot-options');
|
||||
optWrap.style.maxHeight = '160px';
|
||||
optWrap.style.overflowY = 'auto';
|
||||
optWrap.style.marginTop = '0.5rem';
|
||||
products.forEach(function (p) {
|
||||
var lab = el('label');
|
||||
lab.style.display = 'block';
|
||||
var cb = el('input', 'slot-option');
|
||||
cb.type = 'checkbox';
|
||||
cb.value = String(p.id);
|
||||
if (selectedOptions.indexOf(Number(p.id)) !== -1) {
|
||||
cb.checked = true;
|
||||
}
|
||||
lab.appendChild(cb);
|
||||
lab.appendChild(document.createTextNode(' ' + String(p.name)));
|
||||
optWrap.appendChild(lab);
|
||||
});
|
||||
// Type initial : la valeur du slot (edition) ou le 1er type (creation), pour
|
||||
// matcher l'option selectionnee par defaut dans le <select> ci-dessus.
|
||||
var currentType = String(typeSelect.value || (slotTypes.length ? slotTypes[0] : ''));
|
||||
renderOptions(optWrap, currentType, selectedSet);
|
||||
block.appendChild(optWrap);
|
||||
|
||||
// Re-filtrage dynamique : changer le type re-rend les options eligibles. On
|
||||
// repart des cases actuellement cochees (preservees si encore eligibles), pas
|
||||
// de la selection initiale : l'equipier ne reperd pas un choix encore valide.
|
||||
typeSelect.addEventListener('change', function () {
|
||||
var keep = {};
|
||||
Array.prototype.forEach.call(optWrap.querySelectorAll('.slot-option'), function (cb) {
|
||||
if (cb.checked) {
|
||||
keep[String(cb.value)] = true;
|
||||
}
|
||||
});
|
||||
renderOptions(optWrap, String(typeSelect.value), keep);
|
||||
});
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
|
|
@ -157,4 +237,14 @@
|
|||
} else {
|
||||
builder.appendChild(renderSlot(null));
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { init: init, productAllowed: productAllowed, allowedCategories: allowedCategories };
|
||||
}
|
||||
if (typeof document !== 'undefined' && document.addEventListener) {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
init(document);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -158,6 +158,16 @@ final class FakeDatabase implements DatabaseInterface
|
|||
*/
|
||||
public bool $productIsBase = true;
|
||||
|
||||
/**
|
||||
* Slug de categorie renvoye par MenuRepository::productCategorySlug() (garde F12) ;
|
||||
* null => productCategorySlug() retourne null (id inconnu / produit sans categorie),
|
||||
* ce qui fait rejeter l'option par le controleur. Defaut 'boissons' : aligne sur le
|
||||
* slot 'drink' du formulaire valide de reference (validForm), donc une option passe
|
||||
* la garde de categorie par defaut. Un test le change pour simuler une option hors
|
||||
* categorie (ex. 'burgers' dans un slot 'drink').
|
||||
*/
|
||||
public ?string $productCategorySlug = 'boissons';
|
||||
|
||||
/**
|
||||
* Ligne renvoyee par MenuRepository::find() ; null = introuvable.
|
||||
*
|
||||
|
|
@ -463,6 +473,14 @@ final class FakeDatabase implements DatabaseInterface
|
|||
return $this->actingUserRow;
|
||||
}
|
||||
|
||||
// F12 : slug de categorie d'un produit (productCategorySlug), garde de categorie
|
||||
// d'option de slot. Distinguee des routes 'FROM product WHERE id' par le JOIN
|
||||
// category + la projection 'category_slug' ; null => option rejetee (hors
|
||||
// categorie / id inconnu). Doit passer AVANT les routes produit generiques.
|
||||
if (str_contains($sql, 'c.slug AS category_slug FROM product p JOIN category c')) {
|
||||
return $this->productCategorySlug !== null ? ['category_slug' => $this->productCategorySlug] : null;
|
||||
}
|
||||
|
||||
// R4/F9-2 : predicat base-only (productIsBase). Doit passer AVANT la route
|
||||
// generique 'FROM product WHERE id = :id' (productRow) qu'elle matche aussi.
|
||||
if (str_contains($sql, 'FROM product WHERE id = :id') && str_contains($sql, 'base_product_id IS NULL')) {
|
||||
|
|
|
|||
|
|
@ -202,6 +202,38 @@ final class MenuControllerTest extends TestCase
|
|||
self::assertFalse($db->wrote('INSERT INTO menu'));
|
||||
}
|
||||
|
||||
public function testStoreRejectsOptionOutOfCategoryForSlotType(): void
|
||||
{
|
||||
// F12 : garde serveur de categorie. Un slot 'drink' n'autorise que la categorie
|
||||
// 'boissons' ; une option en categorie 'burgers' (UI de filtrage contournee) est
|
||||
// rejetee (422), meme si elle existe et est une base. Defense en profondeur
|
||||
// par-dessus la garde base-only (RG-T18).
|
||||
$db = $this->permittedDb();
|
||||
$db->productIsBase = true; // l'option est bien une base
|
||||
$db->productCategorySlug = 'burgers'; // ... mais hors categorie pour un slot drink
|
||||
|
||||
$response = $this->controller($this->post($this->validForm(), '/admin/menus'), $db)->store();
|
||||
|
||||
self::assertSame(422, $response->status());
|
||||
self::assertFalse($db->wrote('INSERT INTO menu'));
|
||||
self::assertStringContainsString('categorie compatible', $response->body());
|
||||
}
|
||||
|
||||
public function testStoreAcceptsOptionInAllowedCategory(): void
|
||||
{
|
||||
// F12 : symetrique du rejet. Une option en categorie autorisee pour le slot_type
|
||||
// (slot 'drink' + categorie 'boissons') passe la garde et le menu est cree.
|
||||
$db = $this->permittedDb();
|
||||
$db->productIsBase = true;
|
||||
$db->productCategorySlug = 'boissons'; // categorie compatible avec un slot 'drink'
|
||||
|
||||
$response = $this->controller($this->post($this->validForm(), '/admin/menus'), $db)->store();
|
||||
|
||||
self::assertSame(302, $response->status());
|
||||
self::assertTrue($db->wrote('INSERT INTO menu'));
|
||||
self::assertTrue($db->wrote('INSERT INTO menu_slot_option'));
|
||||
}
|
||||
|
||||
public function testStoreRejectsWithoutSlots(): void
|
||||
{
|
||||
$db = $this->permittedDb();
|
||||
|
|
|
|||
152
tests/js/menu-form.test.js
Normal file
152
tests/js/menu-form.test.js
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
* Tests du builder de slots du formulaire menu (back-office), node:test + jsdom.
|
||||
*
|
||||
* F12 : les options proposees dans un slot sont filtrees par le type de slot via le
|
||||
* mapping slot_type -> categories. Cible : init(doc) (rendu jsdom des slots + filtrage
|
||||
* dynamique au changement de type) et le predicat pur productAllowed.
|
||||
*
|
||||
* menu-form.js est du CommonJS (admin = racine CommonJS, comme pin-modal.js) :
|
||||
* import par defaut, init(doc) appele sur un document jsdom prepare.
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import menuForm from '../../src/public/admin/assets/js/menu-form.js';
|
||||
|
||||
// Catalogue minimal (base-only) avec categorie par produit, comme baseOptionsWithCategory.
|
||||
const PRODUCTS = [
|
||||
{ id: 14, name: 'Coca Cola', category: 'boissons' },
|
||||
{ id: 15, name: 'Eau', category: 'boissons' },
|
||||
{ id: 22, name: 'Moyenne Frite', category: 'frites' },
|
||||
{ id: 30, name: 'Nuggets x4', category: 'encas' },
|
||||
{ id: 40, name: 'Cesar Classic', category: 'salades' },
|
||||
{ id: 47, name: 'Ketchup', category: 'sauces' },
|
||||
{ id: 50, name: 'Brownie', category: 'desserts' },
|
||||
{ id: 60, name: 'MC Wrap Chevre', category: 'wraps' },
|
||||
{ id: 70, name: 'Le 280', category: 'burgers' },
|
||||
];
|
||||
|
||||
// Mapping identique a MenuController::SLOT_CATEGORIES (source unique cote serveur).
|
||||
const SLOT_CATEGORIES = {
|
||||
drink: ['boissons'],
|
||||
sauce: ['sauces'],
|
||||
dessert: ['desserts'],
|
||||
side: ['frites', 'encas', 'salades'],
|
||||
extra: ['boissons', 'frites', 'encas', 'wraps', 'salades', 'desserts', 'sauces'],
|
||||
};
|
||||
|
||||
const SLOT_TYPES = ['drink', 'side', 'sauce', 'dessert', 'extra'];
|
||||
|
||||
// Monte un document jsdom porteur du formulaire menu, avec les data-* attendus par
|
||||
// init(). `slots` pre-remplit le builder (edition) ; vide = un slot vierge (creation).
|
||||
function setup(slots) {
|
||||
const dom = new JSDOM(
|
||||
'<!DOCTYPE html><html><body>' +
|
||||
'<form id="menu-form" method="post" action="/admin/menus">' +
|
||||
' <div id="slot-builder"' +
|
||||
' data-products=\'' + JSON.stringify(PRODUCTS) + '\'' +
|
||||
' data-slot-types=\'' + JSON.stringify(SLOT_TYPES) + '\'' +
|
||||
' data-slot-categories=\'' + JSON.stringify(SLOT_CATEGORIES) + '\'' +
|
||||
' data-slots=\'' + JSON.stringify(slots || []) + '\'></div>' +
|
||||
' <button type="button" id="add-slot">Ajouter un slot</button>' +
|
||||
' <input type="hidden" name="slots_json" id="slots_json" value="">' +
|
||||
' <button type="submit">Enregistrer</button>' +
|
||||
'</form></body></html>',
|
||||
);
|
||||
return dom.window.document;
|
||||
}
|
||||
|
||||
// Noms des options affichees dans le 1er bloc slot (ordre du catalogue).
|
||||
function optionNames(doc) {
|
||||
const block = doc.querySelector('.slot-block');
|
||||
return Array.prototype.map.call(block.querySelectorAll('.slot-option'), (cb) => {
|
||||
const id = Number(cb.value);
|
||||
return (PRODUCTS.find((p) => p.id === id) || {}).name;
|
||||
});
|
||||
}
|
||||
|
||||
/* --- productAllowed (pur) ------------------------------------------------- */
|
||||
|
||||
test('productAllowed: un drink n accepte que les boissons', () => {
|
||||
assert.equal(menuForm.productAllowed({ category: 'boissons' }, SLOT_CATEGORIES, 'drink'), true);
|
||||
assert.equal(menuForm.productAllowed({ category: 'sauces' }, SLOT_CATEGORIES, 'drink'), false);
|
||||
});
|
||||
|
||||
test('productAllowed: extra accepte tout sauf menus et burgers', () => {
|
||||
assert.equal(menuForm.productAllowed({ category: 'burgers' }, SLOT_CATEGORIES, 'extra'), false);
|
||||
assert.equal(menuForm.productAllowed({ category: 'menus' }, SLOT_CATEGORIES, 'extra'), false);
|
||||
assert.equal(menuForm.productAllowed({ category: 'wraps' }, SLOT_CATEGORIES, 'extra'), true);
|
||||
assert.equal(menuForm.productAllowed({ category: 'boissons' }, SLOT_CATEGORIES, 'extra'), true);
|
||||
});
|
||||
|
||||
/* --- filtrage des options selon le type de slot --------------------------- */
|
||||
|
||||
test('slot drink (edition) : n affiche que les boissons', () => {
|
||||
const doc = setup([{ name: 'Boisson', slot_type: 'drink', is_required: 1, options: [14] }]);
|
||||
menuForm.init(doc);
|
||||
assert.deepEqual(optionNames(doc), ['Coca Cola', 'Eau']); // pas de frite/sauce/etc.
|
||||
// L option deja cochee (14) reste cochee.
|
||||
const checked = doc.querySelector('.slot-option:checked');
|
||||
assert.equal(checked.value, '14');
|
||||
});
|
||||
|
||||
test('slot side : affiche frites + encas + salades, pas les boissons ni sauces', () => {
|
||||
const doc = setup([{ name: 'Accompagnement', slot_type: 'side', is_required: 1, options: [22] }]);
|
||||
menuForm.init(doc);
|
||||
assert.deepEqual(optionNames(doc), ['Moyenne Frite', 'Nuggets x4', 'Cesar Classic']);
|
||||
});
|
||||
|
||||
test('slot extra : affiche tout sauf burgers (et menus, absent du catalogue de test)', () => {
|
||||
const doc = setup([{ name: 'Extra', slot_type: 'extra', is_required: 0, options: [] }]);
|
||||
menuForm.init(doc);
|
||||
const names = optionNames(doc);
|
||||
assert.ok(!names.includes('Le 280')); // burger exclu
|
||||
assert.ok(names.includes('Coca Cola') && names.includes('Ketchup') && names.includes('MC Wrap Chevre'));
|
||||
});
|
||||
|
||||
/* --- re-filtrage dynamique au changement de type -------------------------- */
|
||||
|
||||
test('changer le type d un slot re-filtre les options proposees', () => {
|
||||
const doc = setup([{ name: 'Boisson', slot_type: 'drink', is_required: 1, options: [14] }]);
|
||||
menuForm.init(doc);
|
||||
assert.deepEqual(optionNames(doc), ['Coca Cola', 'Eau']);
|
||||
|
||||
const typeSelect = doc.querySelector('.slot-type');
|
||||
typeSelect.value = 'sauce';
|
||||
typeSelect.dispatchEvent(new doc.defaultView.Event('change', { bubbles: true }));
|
||||
|
||||
// Apres bascule en 'sauce', seules les sauces restent affichees.
|
||||
assert.deepEqual(optionNames(doc), ['Ketchup']);
|
||||
// L ancienne option boisson (14), non eligible en sauce, a disparu de la liste.
|
||||
assert.equal(doc.querySelector('.slot-option[value="14"]'), null);
|
||||
});
|
||||
|
||||
test('changer de type conserve les options cochees encore eligibles', () => {
|
||||
// drink -> extra : extra inclut boissons, donc les boissons cochees restent cochees.
|
||||
const doc = setup([{ name: 'Boisson', slot_type: 'drink', is_required: 1, options: [14, 15] }]);
|
||||
menuForm.init(doc);
|
||||
|
||||
const typeSelect = doc.querySelector('.slot-type');
|
||||
typeSelect.value = 'extra';
|
||||
typeSelect.dispatchEvent(new doc.defaultView.Event('change', { bubbles: true }));
|
||||
|
||||
const checkedValues = Array.prototype.map.call(
|
||||
doc.querySelectorAll('.slot-option:checked'), (cb) => cb.value,
|
||||
).sort();
|
||||
assert.deepEqual(checkedValues, ['14', '15']); // toujours cochees apres bascule
|
||||
});
|
||||
|
||||
/* --- serialisation a la soumission ---------------------------------------- */
|
||||
|
||||
test('soumission : serialise les slots (options cochees) dans #slots_json', () => {
|
||||
const doc = setup([{ name: 'Boisson', slot_type: 'drink', is_required: 1, options: [14] }]);
|
||||
menuForm.init(doc);
|
||||
doc.getElementById('menu-form').dispatchEvent(
|
||||
new doc.defaultView.Event('submit', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
const payload = JSON.parse(doc.getElementById('slots_json').value);
|
||||
assert.equal(payload.length, 1);
|
||||
assert.equal(payload[0].slot_type, 'drink');
|
||||
assert.deepEqual(payload[0].options, [14]);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue