Files
flowdeck/static/js/_ctx_menu.js
T
bruno 6d7af3fb64
FlowDeck CI / docker (push) Successful in 1m51s
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / test (push) Successful in 15m9s
feat: éditeur visuel d'automations (pipeline steps) + fix CSP multi-instructions (v7.45.0)
Added — Settings → Automations, pipeline visuel (API steps v7.0) :
- Cartes ordinees (trigger/condition/delay/action) : resume + edition
  TYPÉE par kind/type (datalist evenements, 7 ops, 8 types d'action avec
  leurs champs reels), ajout/edition/suppression/haut-bas via
  POST/PUT/DELETE /workspace/automations[/steps]/...
- ✨ Convertir le JSON en pipeline (legacy → steps ordonnes) ; textareas
  JSON masques des qu'un step existe.
- Gate E2E « editeur visuel de steps » : creation → edition → ajout →
  carte « Action · webhook » sous CSP reel.

Fixed (trouve par le gate) — 51 expressions Alpine MULTI-INSTRUCTIONS
(`a=1; b()`) = interdites par le parseur CSP (une seule expression par
directive ; ';' = token inattendu) — INVISIBLE pour le scan par tokens :
- Conversion en methodes dans 11 fichiers : nav settings x8 (navTo),
  menu section base x7 (closeAndSetCount/Move/...), parts+editeur x13
  (setSharePerm, more*, markAndSave...), breadcrumb x5 (hover*/goClose),
  library/local x6 (menus popup), board x3 (pickStatus/...), ctx-menu x2
  (addTagAndClear), agent/card/gitea/workspaces x6.
- Scanner dedie scan_semi (inventaire ';' hors chaines) ajoute au lot.

Verifs : 39 templates Jinja parse OK · scan expressions = 0 incompatible
(4 faux positifs en chaines) · **E2E 8/8** · suite **1094/1094** ·
ruff OK · docs a jour
2026-10-02 16:56:29 -04:00

170 lines
7.1 KiB
JavaScript

/* ═════════════════════════════════════════════════════════════════════
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
inclue ce partial dans base.html ou directement, le js ne s'exécute
qu'une seule fois. Chaque page, à l'ouverture du menu, positionne :
fdCtx.openMenu(evt, node, pageHash, handlers)
─────────────────────────────────────────────────────────────────── */
(function () {
if (window.__fdCtxMenuRegistered) return;
window.__fdCtxMenuRegistered = true;
// Sur un chargement complet de page, ce script inline s'exécute AVANT
// alpine.min.js (defer) → Alpine n'existe pas encore : on attend 'alpine:init'.
// Sur une navigation partielle (fdNavigate → htmx), Alpine est déjà démarré et
// 'alpine:init' ne sera plus jamais émis → on enregistre le store tout de suite,
// sinon le menu contextuel reste mort jusqu'au prochain chargement complet.
function registerFdCtx() {
if (!window.Alpine) return;
if (window.Alpine.__fdCtxStore) return;
window.Alpine.__fdCtxStore = true;
Alpine.store('fdCtx', {
addTagAndClear(value, color, el){
this.addNewTag(value, color);
if (el) el.value = '';
},
open: false, x: 0, y: 0, node: null, page: null,
maxH: 0, _cx: 0, _cy: 0, _ro: null,
handlers: {},
/* Tags (workspace) */
availTags: [], tagAdding: false, tagExistingOpen: false,
newTagColor: '#787774',
tagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
/* Icon picker */
iconOpen: false,
iconChoices: ['📄','📝','📕','📁','⭐','🔖','📌','✅','💡','🔥','🚀','🎯','📊','🗓️','🔗','🧩','📎','🎨','🐛','⚙️','❤️','👍','✨','🏷️','🗒️','📚'],
/* Positionne le menu à l'écran et expose les handlers de la page.
⚠️ Nom : openMenu (et PAS open) — « open » est réservé au booléen
d'état x-show. Une collision ici rendrait le menu PERMANENT. */
openMenu(ev, node, pageHash, handlers) {
handlers = handlers || {};
this.node = node;
this.page = pageHash;
this.handlers = handlers;
this.tagAdding = false;
this.tagExistingOpen = false;
this.iconOpen = false;
this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774';
var cx = (ev && ev.clientX) || 0;
var cy = (ev && ev.clientY) || 0;
this._cx = cx;
this._cy = cy;
this.x = Math.max(8, Math.min(cx, window.innerWidth - 240));
this.y = Math.max(8, Math.min(cy, window.innerHeight - 48));
this.open = true;
var self = this;
/* Mesure la taille réelle du menu (une fois rendu) puis le replace
pour qu'il reste TOUJOURS entièrement visible dans le viewport.
Un ResizeObserver relance le placement à chaque fois que le menu
change de taille (ouverture d'un sous-menu « tag », icônes, …),
sinon il grandissait vers le bas et sortait de l'écran. */
var after = function () {
self._place();
var el = document.querySelector('.fd-ctx-menu');
if (el && window.ResizeObserver) {
if (self._ro) { try { self._ro.disconnect(); } catch { /* volontaire */ } }
self._ro = new ResizeObserver(function () { self._place(); });
self._ro.observe(el);
}
};
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(after);
else setTimeout(after, 0);
},
/* Reclasse le menu dans le viewport et limite sa hauteur à l'espace
disponible sous son ancre (le contenu déborde en scroll interne). */
_place() {
var el = document.querySelector('.fd-ctx-menu');
if (!el) return;
var prev = el.style.maxHeight;
el.style.maxHeight = 'none';
var w = el.offsetWidth || 240;
var h = el.offsetHeight || 320;
el.style.maxHeight = prev || '';
var vw = window.innerWidth, vh = window.innerHeight;
var cx = (this._cx == null ? this.x : this._cx);
var cy = (this._cy == null ? this.y : this._cy);
var nx = cx;
if (nx + w > vw - 8) nx = vw - w - 8;
nx = Math.max(8, nx);
var ny = cy;
if (ny + h > vh - 8) {
if (cy - h >= 8) ny = cy - h;
else ny = Math.max(8, vh - h - 8);
}
this.x = nx;
this.y = ny;
this.maxH = Math.max(120, vh - ny - 8);
},
close() {
if (this._ro) { try { this._ro.disconnect(); } catch { /* volontaire */ } this._ro = null; }
this.open = false;
this.node = null;
this.handlers = {};
this.tagAdding = false;
this.tagExistingOpen = false;
this.iconOpen = false;
},
/* Un item est-il disponible pour CETTE page ? (union vs capacités) */
has(key) { return typeof this.handlers[key] === 'function'; },
/* Exécute l'action → handler fourni par la page courante. */
run(key) {
if (!this.node) { this.close(); return; }
var fn = this.handlers[key];
if (fn) try { fn(this.node); } catch (e) { console.error('fdCtx.run', e); }
this.close();
},
/* ── Tags ── */
avail() { return this.availTags || []; },
setAvail(a) { this.availTags = a || []; },
removeTag(id) {
var fn = this.handlers.tagRemove;
if (fn) try { fn(id); } catch (e) { console.error('fdCtx.removeTag', e); }
},
addExistingTag(t) {
var fn = this.handlers.tagExisting;
if (fn) try { fn(t); } catch (e) { console.error('fdCtx.addExistingTag', e); }
this.tagExistingOpen = false;
},
addNewTag(name, color) {
name = (name || '').trim();
if (!name) return;
var fn = this.handlers.tagAdd;
if (fn) try { fn(name, color || this.newTagColor); } catch (e) { console.error('fdCtx.addNewTag', e); }
this.tagAdding = false;
},
/* ── Icon picker ── */
setIcon(icon) {
icon = (icon || '').trim();
var fn = this.handlers.setIcon;
if (fn) try { fn(icon); } catch (e) { console.error('fdCtx.setIcon', e); }
this.close();
},
openIconPicker() {
var fn = this.handlers.setIcon;
if (!fn) return;
if (!window.FDIconPicker) return;
window.FDIconPicker.openFor({
x: this.x,
y: this.y,
onPick: fn,
onRemove: function () { fn(''); }
});
this.close();
},
});
}
if (window.Alpine) {
registerFdCtx();
} else {
document.addEventListener('alpine:init', registerFdCtx);
}
})();