feat(catalogue): options de slot filtrees par type de slot + garde serveur (#113)
This commit is contained in:
parent
be4585aeb2
commit
27578dd7fe
8 changed files with 534 additions and 133 deletions
|
|
@ -164,6 +164,27 @@ final class MenuRepository
|
||||||
) !== null;
|
) !== 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
|
* 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.
|
* 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
|
* @return array<string, mixed>|null
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,32 @@ class MenuController extends AdminController
|
||||||
{
|
{
|
||||||
private const SLOT_TYPES = ['drink', 'side', 'sauce', 'dessert', 'extra'];
|
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
|
* @param array<string, string> $params
|
||||||
*/
|
*/
|
||||||
|
|
@ -388,13 +414,22 @@ class MenuController extends AdminController
|
||||||
$slotType = is_string($raw['slot_type'] ?? null) ? $raw['slot_type'] : '';
|
$slotType = is_string($raw['slot_type'] ?? null) ? $raw['slot_type'] : '';
|
||||||
$required = !empty($raw['is_required']) ? 1 : 0;
|
$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
|
// 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
|
// variante de taille (base_product_id non nul) est REJETE explicitement
|
||||||
// (422) plutot que filtre en silence : choisir une variante comme option
|
// (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
|
// 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).
|
// 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 = [];
|
$optionIds = [];
|
||||||
$hasVariantOption = false;
|
$hasVariantOption = false;
|
||||||
|
$hasWrongCategoryOption = false;
|
||||||
foreach (is_array($raw['options'] ?? null) ? $raw['options'] : [] as $opt) {
|
foreach (is_array($raw['options'] ?? null) ? $raw['options'] : [] as $opt) {
|
||||||
$pid = is_numeric($opt) ? (int) $opt : 0;
|
$pid = is_numeric($opt) ? (int) $opt : 0;
|
||||||
if ($pid <= 0 || !$this->menuRepository()->productExists($pid)) {
|
if ($pid <= 0 || !$this->menuRepository()->productExists($pid)) {
|
||||||
|
|
@ -404,6 +439,11 @@ class MenuController extends AdminController
|
||||||
$hasVariantOption = true;
|
$hasVariantOption = true;
|
||||||
continue; // variante de taille : non eligible comme option de menu
|
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[] = $pid;
|
||||||
}
|
}
|
||||||
$optionIds = array_values(array_unique($optionIds));
|
$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).';
|
$errors['slots'] = 'Une variante de taille ne peut pas etre proposee comme option de menu (choisissez le produit de base).';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if ($hasWrongCategoryOption) {
|
||||||
|
$errors['slots'] = 'Une option proposee n\'appartient pas a une categorie compatible avec le type de ce slot.';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if ($optionIds === []) {
|
if ($optionIds === []) {
|
||||||
$errors['slots'] = 'Chaque slot doit proposer au moins une option valide.';
|
$errors['slots'] = 'Chaque slot doit proposer au moins une option valide.';
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -479,9 +523,16 @@ class MenuController extends AdminController
|
||||||
'categories' => $this->categoryRepository()->all(),
|
'categories' => $this->categoryRepository()->all(),
|
||||||
// F9-1 : listes deroulantes base-only (burger principal + options de
|
// F9-1 : listes deroulantes base-only (burger principal + options de
|
||||||
// slot). basesOnly() exclut les variantes de taille (R4) ; all() les
|
// 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(),
|
'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,
|
'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' => [
|
'values' => [
|
||||||
'category_id' => (string) ($values['category_id'] ?? ''),
|
'category_id' => (string) ($values['category_id'] ?? ''),
|
||||||
'burger_product_id' => (string) ($values['burger_product_id'] ?? ''),
|
'burger_product_id' => (string) ($values['burger_product_id'] ?? ''),
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ declare(strict_types=1);
|
||||||
*
|
*
|
||||||
* @var int $menuId
|
* @var int $menuId
|
||||||
* @var array<int, array<string, mixed>> $categories
|
* @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 list<string> $slotTypes
|
||||||
|
* @var array<string, list<string>> $slotCategories slot_type -> categories autorisees (F12)
|
||||||
* @var array<string, mixed> $values
|
* @var array<string, mixed> $values
|
||||||
* @var string $slotsJson
|
* @var string $slotsJson
|
||||||
* @var array<string, string> $errors
|
* @var array<string, string> $errors
|
||||||
|
|
@ -30,8 +32,12 @@ $errs = isset($errors) && is_array($errors) ? $errors : [];
|
||||||
$cats = isset($categories) && is_array($categories) ? $categories : [];
|
$cats = isset($categories) && is_array($categories) ? $categories : [];
|
||||||
/** @var array<int, array<string, mixed>> $prods */
|
/** @var array<int, array<string, mixed>> $prods */
|
||||||
$prods = isset($products) && is_array($products) ? $products : [];
|
$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 */
|
/** @var list<string> $types */
|
||||||
$types = isset($slotTypes) && is_array($slotTypes) ? $slotTypes : [];
|
$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');
|
$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] : '';
|
$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
|
// 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.
|
// 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(
|
$slimProducts = array_map(
|
||||||
static fn (array $p): array => ['id' => (int) ($p['id'] ?? 0), 'name' => (string) ($p['name'] ?? '')],
|
static fn (array $p): array => [
|
||||||
$prods,
|
'id' => (int) ($p['id'] ?? 0),
|
||||||
|
'name' => (string) ($p['name'] ?? ''),
|
||||||
|
'category' => (string) ($p['category_slug'] ?? ''),
|
||||||
|
],
|
||||||
|
$slotProds,
|
||||||
);
|
);
|
||||||
$attr = static fn (mixed $data): string => htmlspecialchars(
|
$attr = static fn (mixed $data): string => htmlspecialchars(
|
||||||
(string) json_encode($data, JSON_UNESCAPED_UNICODE),
|
(string) json_encode($data, JSON_UNESCAPED_UNICODE),
|
||||||
|
|
@ -124,6 +137,7 @@ $slotsData = isset($slotsJson) && is_string($slotsJson) && $slotsJson !== '' ? $
|
||||||
<div id="slot-builder"
|
<div id="slot-builder"
|
||||||
data-products="<?= $attr($slimProducts) ?>"
|
data-products="<?= $attr($slimProducts) ?>"
|
||||||
data-slot-types="<?= $attr($types) ?>"
|
data-slot-types="<?= $attr($types) ?>"
|
||||||
|
data-slot-categories="<?= $attr($slotCats) ?>"
|
||||||
data-slots="<?= htmlspecialchars($slotsData, ENT_QUOTES, 'UTF-8') ?>"></div>
|
data-slots="<?= htmlspecialchars($slotsData, ENT_QUOTES, 'UTF-8') ?>"></div>
|
||||||
<button class="btn btn-secondary" type="button" id="add-slot">Ajouter un slot</button>
|
<button class="btn btn-secondary" type="button" id="add-slot">Ajouter un slot</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,34 @@
|
||||||
* menu-form.js — Builder de slots du formulaire menu (back-office).
|
* menu-form.js — Builder de slots du formulaire menu (back-office).
|
||||||
*
|
*
|
||||||
* CSP 'self' : script externe (pas d'inline). Les donnees (produits, types,
|
* CSP 'self' : script externe (pas d'inline). Les donnees (produits, types,
|
||||||
* slots initiaux) sont lues depuis les attributs data-* de #slot-builder.
|
* mapping slot_type -> categories, slots initiaux) sont lues depuis les attributs
|
||||||
* A la soumission, l'etat des slots est serialise en JSON dans le champ cache
|
* data-* de #slot-builder. A la soumission, l'etat des slots est serialise en JSON
|
||||||
* #slots_json (Request::formBody cote serveur ne garde que les scalaires, d'ou
|
* dans le champ cache #slots_json (Request::formBody cote serveur ne garde que les
|
||||||
* le passage par une chaine JSON). Le serveur revalide tout (RG-T18).
|
* 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 () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var builder = document.getElementById('slot-builder');
|
function el(doc, tag, className) {
|
||||||
var form = document.getElementById('menu-form');
|
var e = doc.createElement(tag);
|
||||||
var hidden = document.getElementById('slots_json');
|
if (className) {
|
||||||
var addBtn = document.getElementById('add-slot');
|
e.className = className;
|
||||||
if (!builder || !form || !hidden || !addBtn) {
|
}
|
||||||
return;
|
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 {
|
try {
|
||||||
var v = JSON.parse(builder.dataset[key] || fallback);
|
var v = JSON.parse(builder.dataset[key] || fallback);
|
||||||
return Array.isArray(v) ? v : JSON.parse(fallback);
|
return Array.isArray(v) ? v : JSON.parse(fallback);
|
||||||
|
|
@ -27,134 +38,213 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var products = parseData('products', '[]'); // [{id, name}]
|
// Lit un attribut data-* JSON et retourne un objet simple (le mapping slot_type ->
|
||||||
var slotTypes = parseData('slotTypes', '[]'); // ['drink', 'side', ...]
|
// categories), sinon {} ; tolerant aux entrees non-objet / mal formees.
|
||||||
var initialSlots = parseData('slots', '[]'); // [{name, slot_type, is_required, options:[id]}]
|
function parseObject(builder, key) {
|
||||||
|
try {
|
||||||
function el(tag, className) {
|
var v = JSON.parse(builder.dataset[key] || '{}');
|
||||||
var e = document.createElement(tag);
|
return (v && typeof v === 'object' && !Array.isArray(v)) ? v : {};
|
||||||
if (className) {
|
} catch (e) {
|
||||||
e.className = className;
|
return {};
|
||||||
}
|
}
|
||||||
return e;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construit le bloc DOM d'un slot. `slot` peut etre vide (creation).
|
// Categories eligibles pour un slot_type donne (liste, vide si type inconnu).
|
||||||
function renderSlot(slot) {
|
function allowedCategories(slotCategories, slotType) {
|
||||||
slot = slot || {};
|
var list = slotCategories[slotType];
|
||||||
var selectedOptions = Array.isArray(slot.options) ? slot.options.map(Number) : [];
|
return Array.isArray(list) ? list : [];
|
||||||
|
|
||||||
var block = el('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');
|
|
||||||
|
|
||||||
// Nom du slot
|
|
||||||
var nameLabel = el('label');
|
|
||||||
nameLabel.appendChild(document.createTextNode('Nom du slot '));
|
|
||||||
var nameInput = el('input', 'form-input slot-name');
|
|
||||||
nameInput.type = 'text';
|
|
||||||
nameInput.maxLength = 80;
|
|
||||||
nameInput.value = slot.name ? String(slot.name) : '';
|
|
||||||
nameLabel.appendChild(nameInput);
|
|
||||||
head.appendChild(nameLabel);
|
|
||||||
|
|
||||||
// Type
|
|
||||||
var typeLabel = el('label');
|
|
||||||
typeLabel.appendChild(document.createTextNode(' Type '));
|
|
||||||
var typeSelect = el('select', 'form-input slot-type');
|
|
||||||
slotTypes.forEach(function (t) {
|
|
||||||
var opt = el('option');
|
|
||||||
opt.value = String(t);
|
|
||||||
opt.textContent = String(t);
|
|
||||||
if (String(slot.slot_type) === String(t)) {
|
|
||||||
opt.selected = true;
|
|
||||||
}
|
|
||||||
typeSelect.appendChild(opt);
|
|
||||||
});
|
|
||||||
typeLabel.appendChild(typeSelect);
|
|
||||||
head.appendChild(typeLabel);
|
|
||||||
|
|
||||||
// Requis
|
|
||||||
var reqLabel = el('label');
|
|
||||||
var reqInput = el('input', 'slot-required');
|
|
||||||
reqInput.type = 'checkbox';
|
|
||||||
if (Number(slot.is_required) === 1) {
|
|
||||||
reqInput.checked = true;
|
|
||||||
}
|
|
||||||
reqLabel.appendChild(reqInput);
|
|
||||||
reqLabel.appendChild(document.createTextNode(' Requis'));
|
|
||||||
head.appendChild(reqLabel);
|
|
||||||
|
|
||||||
// Retirer
|
|
||||||
var removeBtn = el('button', 'btn btn-secondary slot-remove');
|
|
||||||
removeBtn.type = 'button';
|
|
||||||
removeBtn.textContent = 'Retirer';
|
|
||||||
removeBtn.addEventListener('click', function () {
|
|
||||||
block.parentNode.removeChild(block);
|
|
||||||
});
|
|
||||||
head.appendChild(removeBtn);
|
|
||||||
|
|
||||||
block.appendChild(head);
|
|
||||||
|
|
||||||
// Options : cases a cocher des produits eligibles
|
|
||||||
var optWrap = el('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);
|
|
||||||
});
|
|
||||||
block.appendChild(optWrap);
|
|
||||||
|
|
||||||
return block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lit l'etat des blocs et le serialise dans #slots_json.
|
// Un produit est-il proposable dans un slot de ce type ? (sa categorie figure dans
|
||||||
function serialize() {
|
// la liste autorisee). Source unique de la decision UI, miroir de la garde serveur.
|
||||||
var slots = [];
|
function productAllowed(product, slotCategories, slotType) {
|
||||||
var blocks = builder.querySelectorAll('.slot-block');
|
return allowedCategories(slotCategories, slotType).indexOf(String(product.category)) !== -1;
|
||||||
Array.prototype.forEach.call(blocks, function (block) {
|
}
|
||||||
var name = block.querySelector('.slot-name').value.trim();
|
|
||||||
var type = block.querySelector('.slot-type').value;
|
function init(doc) {
|
||||||
var required = block.querySelector('.slot-required').checked ? 1 : 0;
|
var builder = doc.getElementById('slot-builder');
|
||||||
var options = [];
|
var form = doc.getElementById('menu-form');
|
||||||
Array.prototype.forEach.call(block.querySelectorAll('.slot-option'), function (cb) {
|
var hidden = doc.getElementById('slots_json');
|
||||||
if (cb.checked) {
|
var addBtn = doc.getElementById('add-slot');
|
||||||
options.push(Number(cb.value));
|
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);
|
||||||
});
|
});
|
||||||
slots.push({ name: name, slot_type: type, is_required: required, options: options });
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construit le bloc DOM d'un slot. `slot` peut etre vide (creation).
|
||||||
|
function renderSlot(slot) {
|
||||||
|
slot = slot || {};
|
||||||
|
var selectedSet = {};
|
||||||
|
(Array.isArray(slot.options) ? slot.options : []).forEach(function (id) {
|
||||||
|
selectedSet[String(Number(id))] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
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(doc, 'div');
|
||||||
|
|
||||||
|
// Nom du slot
|
||||||
|
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) : '';
|
||||||
|
nameLabel.appendChild(nameInput);
|
||||||
|
head.appendChild(nameLabel);
|
||||||
|
|
||||||
|
// 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(doc, 'option');
|
||||||
|
opt.value = String(t);
|
||||||
|
opt.textContent = String(t);
|
||||||
|
if (String(slot.slot_type) === String(t)) {
|
||||||
|
opt.selected = true;
|
||||||
|
}
|
||||||
|
typeSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
typeLabel.appendChild(typeSelect);
|
||||||
|
head.appendChild(typeLabel);
|
||||||
|
|
||||||
|
// Requis
|
||||||
|
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(doc.createTextNode(' Requis'));
|
||||||
|
head.appendChild(reqLabel);
|
||||||
|
|
||||||
|
// Retirer
|
||||||
|
var removeBtn = el(doc, 'button', 'btn btn-secondary slot-remove');
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.textContent = 'Retirer';
|
||||||
|
removeBtn.addEventListener('click', function () {
|
||||||
|
block.parentNode.removeChild(block);
|
||||||
|
});
|
||||||
|
head.appendChild(removeBtn);
|
||||||
|
|
||||||
|
block.appendChild(head);
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lit l'etat des blocs et le serialise dans #slots_json.
|
||||||
|
function serialize() {
|
||||||
|
var slots = [];
|
||||||
|
var blocks = builder.querySelectorAll('.slot-block');
|
||||||
|
Array.prototype.forEach.call(blocks, function (block) {
|
||||||
|
var name = block.querySelector('.slot-name').value.trim();
|
||||||
|
var type = block.querySelector('.slot-type').value;
|
||||||
|
var required = block.querySelector('.slot-required').checked ? 1 : 0;
|
||||||
|
var options = [];
|
||||||
|
Array.prototype.forEach.call(block.querySelectorAll('.slot-option'), function (cb) {
|
||||||
|
if (cb.checked) {
|
||||||
|
options.push(Number(cb.value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
slots.push({ name: name, slot_type: type, is_required: required, options: options });
|
||||||
|
});
|
||||||
|
hidden.value = JSON.stringify(slots);
|
||||||
|
}
|
||||||
|
|
||||||
|
addBtn.addEventListener('click', function () {
|
||||||
|
builder.appendChild(renderSlot(null));
|
||||||
});
|
});
|
||||||
hidden.value = JSON.stringify(slots);
|
|
||||||
|
form.addEventListener('submit', function () {
|
||||||
|
serialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rendu initial : slots existants (edition) ou un slot vide (creation).
|
||||||
|
if (initialSlots.length) {
|
||||||
|
initialSlots.forEach(function (s) {
|
||||||
|
builder.appendChild(renderSlot(s));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
builder.appendChild(renderSlot(null));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addBtn.addEventListener('click', function () {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
builder.appendChild(renderSlot(null));
|
module.exports = { init: init, productAllowed: productAllowed, allowedCategories: allowedCategories };
|
||||||
});
|
}
|
||||||
|
if (typeof document !== 'undefined' && document.addEventListener) {
|
||||||
form.addEventListener('submit', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
serialize();
|
init(document);
|
||||||
});
|
|
||||||
|
|
||||||
// Rendu initial : slots existants (edition) ou un slot vide (creation).
|
|
||||||
if (initialSlots.length) {
|
|
||||||
initialSlots.forEach(function (s) {
|
|
||||||
builder.appendChild(renderSlot(s));
|
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
builder.appendChild(renderSlot(null));
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,16 @@ final class FakeDatabase implements DatabaseInterface
|
||||||
*/
|
*/
|
||||||
public bool $productIsBase = true;
|
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.
|
* Ligne renvoyee par MenuRepository::find() ; null = introuvable.
|
||||||
*
|
*
|
||||||
|
|
@ -463,6 +473,14 @@ final class FakeDatabase implements DatabaseInterface
|
||||||
return $this->actingUserRow;
|
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
|
// R4/F9-2 : predicat base-only (productIsBase). Doit passer AVANT la route
|
||||||
// generique 'FROM product WHERE id = :id' (productRow) qu'elle matche aussi.
|
// 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')) {
|
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'));
|
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
|
public function testStoreRejectsWithoutSlots(): void
|
||||||
{
|
{
|
||||||
$db = $this->permittedDb();
|
$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