feat(templates): refonte complète des templates façon Notion + vues/agents-skills
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:
@@ -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); }
|
||||
|
||||
+1241
-11
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user