feat(templates): refonte complète des templates façon Notion + vues/agents-skills
FlowDeck CI / lint (push) Failing after 1m32s
FlowDeck CI / test (push) Failing after 27m50s
FlowDeck CI / docker (push) Skipped

Templates (v7.71.x) :

- registre unifié \	emplates\ (migrations 48-49) + TemplateService.instantiate unique (UI, API v2, agent, scheduler)

- sélecteur (pilule page vide, menu •••, commande /template), gestionnaire /templates, menu New ▾, From template, base inline dans un document

- 141 presets système (59 pages, 42 bases, 15 blocs, 25 lignes), titre auto depuis le template, variables title réservée

- récurrences RRULE + scheduler dédupliqué, agent apply_template/list_templates, API /api/templates + /api/v2/fd-templates

- correctifs : bouton Templates, centrage fenêtre, filtres CSP, flux de création, variable title

- tests : tests/test_fd_templates.py (19) et e2e/templates_picker.spec.js (8)

Inclut le travail déjà présent dans le working tree (vues Notion : view_query/view_aggregate/form_projection/geocoding, property_types, database_table, docs agents-skills) et ignore .playwright-mcp/.
This commit is contained in:
2026-10-10 18:52:19 -04:00
parent d0452e10dd
commit fc8548194a
36 changed files with 12290 additions and 137 deletions
+40
View File
@@ -5269,3 +5269,43 @@ body.page-locked .get-started-toolbar { display: none !important; }
.tpl-card-icon { font-size: 20px; line-height: 1.2; }
.tpl-card-name { font-size: 13.5px; font-weight: 600; }
.tpl-card-desc { font-size: 12px; color: var(--text-dim, #999); margin-top: 2px; }
/* ── Unified template picker (registre) + filtre partagé ─────────────── */
.fd-tpl-filter {
border: none;
background: transparent;
color: var(--text-dim, #999);
font-size: 12.5px;
font-weight: 500;
padding: 5px 12px;
border-radius: 7px;
cursor: pointer;
transition: background .12s ease, color .12s ease;
}
.fd-tpl-filter:hover { background: var(--bg-hover); color: var(--text-primary); }
.fd-tpl-filter.active { background: var(--accent); color: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.25); }
.fd-gtpl-overlay { position: fixed; inset: 0; z-index: 2100; background: rgba(0,0,0,.5); display: none; }
.fd-gtpl-overlay.open { display: flex; align-items: center; justify-content: center; padding: 20px; }
.fd-gtpl {
width: 640px; max-width: 96vw; max-height: 80vh; display: flex; flex-direction: column;
background: var(--bg-modal, #fff); border: 1px solid var(--border); border-radius: 14px;
box-shadow: 0 24px 70px rgba(0,0,0,.4); overflow: hidden;
}
.fd-gtpl-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
.fd-gtpl-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.fd-gtpl-x { background: none; border: none; color: var(--text-dim); font-size: 20px; line-height: 1; cursor: pointer; padding: 2px 6px; border-radius: 6px; }
.fd-gtpl-x:hover { color: var(--text-primary); background: var(--bg-hover); }
.fd-gtpl-body { padding: 12px 20px; overflow-y: auto; }
.fd-gtpl-search { width: 100%; padding: 8px 12px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 8px; color: var(--text-primary); font-size: 14px; outline: none; box-sizing: border-box; }
.fd-gtpl-search:focus { border-color: var(--accent); }
.fd-gtpl-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 10px; padding: 3px; margin: 10px 0 12px; }
.fd-gtpl-list { display: flex; flex-direction: column; gap: 8px; min-height: 80px; }
.fd-gtpl-card { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; cursor: pointer; background: var(--bg-secondary); transition: border-color .12s ease, background .12s ease; }
.fd-gtpl-card:hover { border-color: var(--accent); background: var(--bg-hover); }
.fd-gtpl-card.sel { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
.fd-gtpl-ico { font-size: 20px; line-height: 1; }
.fd-gtpl-name { font-weight: 600; font-size: 14px; color: var(--text-primary); }
.fd-gtpl-meta { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
.fd-gtpl-empty { color: var(--text-dim); font-size: 13px; padding: 20px 0; text-align: center; }
.fd-gtpl-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border); }
File diff suppressed because it is too large Load Diff
+141 -1
View File
@@ -21,6 +21,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
{id:'audio',name:'Audio',icon:'🎵'},
]},
{name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]},
{name:'TEMPLATES',items:[{id:'template',name:'Templates (base, ligne, blocs)',icon:'🧩'}]},
{name:'SYNCED',items:[{id:'synced',name:'Synced block',icon:'🔗'}]},
{name:'ACTIONS',items:[{id:'duplicate',name:'Duplicate',icon:'▣'},{id:'turn_into',name:'Turn into…',icon:'↩'},{id:'copy_link',name:'Copy link to block',icon:'🔗'},{id:'delete',name:'Delete block',icon:'✕'}]}];
@@ -1492,6 +1493,12 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
tplSaving:false,
// ── DB Templates ──
dbTplOpen:false,dbTplLoading:false,dbTemplates:[],
// ── Unified template picker (registre unifié §7.1 : la pilule
// « Templates » ouvre TOUJOURS ce panneau — jamais un no-op, P5) ──
tplOpen:false,tplLoading:false,tplItems:[],tplQuery:'',tplKind:'all',
tplError:'',tplSelected:null,tplPreviewBlocks:[],tplPreviewTitle:'',
tplPreviewWarnings:[],tplVarDefs:[],tplVars:{},tplApplying:false,
tplRestrict:null,tplKindOptionsList:['page','database','row','blocks','all'],
// ── Version history ──
versionsOpen:false,versionsLoading:false,versionsList:[],
// ── Backlinks ──
@@ -2560,7 +2567,6 @@ applyAIBlocks(text){
self.dbTemplates=d.templates||[];
}catch(e){ self.dbTemplates=[]; }
self.dbTplLoading=false;
self.$nextTick(()=>{ const f=document.getElementById('fd-dbtpl'); if(f) setTimeout(()=>{f.style.opacity='1';},10); });
},
closeDbTemplatePicker(){ this.dbTplOpen=false; },
async createDbFromTemplate(tplName){
@@ -2588,6 +2594,139 @@ applyAIBlocks(text){
}
}catch(e){ this.showToast('Error: '+e.message); }
},
// ── Unified template picker (§7.1) ──
openTplPicker(opts){
opts=opts||{};
this.tplRestrict=(opts.kinds&&opts.kinds.length)?opts.kinds:null;
this.tplKindOptionsList=this.tplRestrict?['all'].concat(this.tplRestrict):['page','database','row','blocks','all'];
this.tplKind='all';
this.gsMoreOpen=false; this.moreOpen=false;
this.tplOpen=true; this.tplSelected=null; this.tplPreviewBlocks=[];
this.tplPreviewTitle=''; this.tplPreviewWarnings=[]; this.tplVarDefs=[];
this.tplVars={}; this.tplError='';
this.loadTplList();
},
closeTplPicker(){ this.tplOpen=false; },
// Build CSP : une seule expression par handler (pas de `;`).
setTplKind(k){ this.tplKind=k; this.loadTplList(); },
setTplVar(name,value){ this.tplVars[name]=value; this.previewTpl(); },
tplKindLabel(k){ return {all:'Tous',page:'Pages',blocks:'Blocs',row:'Lignes',database:'Bases'}[k]||k; },
async loadTplList(){
this.tplLoading=true; this.tplError='';
var self=this;
try{
var p=new URLSearchParams();
if(this.tplKind!=='all') p.set('kind',this.tplKind);
if(this.tplQuery) p.set('q',this.tplQuery);
const r=await fetch('/api/templates?'+p.toString(),{credentials:'same-origin'});
if(!r.ok) throw new Error('HTTP '+r.status);
const d=await r.json();
var items=d.templates||[];
// Vue restreinte (slash /template : base, ligne, blocs — pas de page).
if(this.tplRestrict) items=items.filter(function(t){return self.tplRestrict.indexOf(t.kind)>=0;});
self.tplItems=items;
}catch(e){ self.tplItems=[]; self.tplError='Chargement impossible : '+e.message; }
self.tplLoading=false;
},
async selectTpl(t){
this.tplSelected=t; this.tplVars={}; this.tplPreviewWarnings=[];
this.tplPreviewBlocks=[]; this.tplPreviewTitle=t.name;
try{
const r=await fetch('/api/templates/'+encodeURIComponent(t.id),{credentials:'same-origin'});
if(!r.ok) throw new Error('HTTP '+r.status);
const d=await r.json();
this.tplVarDefs=d.variables||[];
this.tplPreviewBlocks=(d.blocks_preview||[]);
this.tplPreviewTitle=d.name;
this.previewTpl();
}catch(e){ this.tplPreviewWarnings=['Aperçu indisponible : '+e.message]; }
},
async previewTpl(){
if(!this.tplSelected) return;
try{
const csrf = getCsrf();
const r=await fetch('/api/templates/'+encodeURIComponent(this.tplSelected.id)+'/preview',{
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},
credentials:'same-origin',body:JSON.stringify({variables:this.tplVars})
});
if(!r.ok) return;
const d=await r.json();
this.tplPreviewTitle=d.title||this.tplSelected.name;
this.tplPreviewBlocks=d.blocks_preview||this.tplPreviewBlocks;
this.tplPreviewWarnings=d.warnings||[];
}catch(e){}
},
async applyTpl(){
if(!this.tplSelected||this.tplApplying) return;
this.tplApplying=true;
const self=this; const t=this.tplSelected;
const csrf = getCsrf();
try{
var body={variables:this.tplVars,context:{}};
if(t.kind==='blocks'||t.kind==='page'){
body.page_id=this.pid; body.mode='fill';
}
if(t.kind==='page'){
// Le titre suit le template ; {{title}} = sujet saisi, sinon le
// titre actuel de la page s'il est significatif, sinon le nom du
// template (côté serveur).
var pt=(this.pageTitle||'').trim();
if(!body.variables.title && pt && pt!=='Untitled' && pt!=='Sans titre'){ body.variables.title=pt; }
}
if(t.kind==='database'){
// Base INLINE dans le document courant (pas de navigation) :
// même patron que la pilule « Database » (createDbFromTemplate).
body.parent_page_id=this.pid;
}
// kind=row : le serveur utilise la base propriétaire du template ;
// kind=database hors page : une base neuve est créée (navigation vers elle).
const r=await fetch('/api/templates/'+encodeURIComponent(t.id)+'/instantiate',{
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},
credentials:'same-origin',body:JSON.stringify(body)
});
if(!r.ok){ const txt=await r.text(); throw new Error('HTTP '+r.status+' '+txt.slice(0,200)); }
const d=await r.json();
(d.warnings||[]).forEach(function(w){ self.showToast(w,'error'); });
if(t.kind==='blocks'&&d.blocks){
const idx=self._tplInsertIndex();
const fresh=d.blocks.map(function(b){ b.id=genId(); return b; });
self.blocks.splice.apply(self.blocks,[idx,0].concat(fresh));
self.dirty=true; self.autoSave(); self.render();
self.showToast('Blocs insérés depuis « '+t.name+' »');
}else if(t.kind==='database'&&d.created_id){
const idx=self._tplInsertIndex();
const nb=self.mkB('embed','',{embed_type:'collection',collection_id:parseInt(d.created_id,10)});
self.blocks.splice(idx,0,nb);
self.dirty=true; self.autoSave(); self.render();
self.showToast('Base « '+t.name+' » insérée dans le document');
}else if(body.page_id){
// Page courante remplie en place (pas de navigation, §7.1).
const fresh=(d.blocks||[]).map(function(b){ if(!b.id) b.id=genId(); return b; });
if(fresh.length) self.blocks=fresh;
if(d.title){ self.pageTitle=d.title; var te=document.getElementById('_titleEl'); if(te){ te.textContent=d.title; te.classList.toggle('empty',!(d.title||'').trim()); } }
self.dirty=true; self.autoSave(); self.render();
self.showToast('Page remplie depuis « '+t.name+' »');
}else if(t.kind==='row'&&d.url){
// Appliqué depuis un document : on ne quitte pas la page, on
// signale la ligne créée dans sa base.
self.showToast('Ligne « '+t.name+' » créée dans sa base');
}else if(d.url){
if(window.fdNavigate) window.fdNavigate(d.url); else window.location=d.url;
}else{
self.showToast('Créé depuis « '+t.name+' »');
}
// Undo via le toast : le run est journalisé côté serveur.
self.tplOpen=false;
}catch(e){ self.showToast('Échec : '+e.message,'error'); }
self.tplApplying=false;
},
_tplInsertIndex(){
try{
const ab=this.getActiveBlock();
if(ab&&typeof ab.idx==='number') return ab.idx+1;
}catch(e){}
return this.blocks.length;
},
async createForm(){
const name=prompt('Form name:','New Form');
if(!name||!name.trim())return;
@@ -2721,6 +2860,7 @@ applyAIBlocks(text){
},
applySlash(id){
const idx=SM._i;if(idx<0||idx>=this.blocks.length)return;
if(id==='template'){ this.openTplPicker({kinds:['database','row','blocks']}); return; }
if(id==='database'){ this.openDbTemplatePicker(idx); return; }
if(id==='synced'){
const block=this.blocks[idx];this.sync();this.pushHistory();