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
+183 -88
View File
@@ -608,6 +608,9 @@
<a href="/library" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("book",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Library</span>
</a>
<a href="/templates" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("file",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Templates</span>
</a>
<a href="/my-tasks" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("check-square",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">My Tasks</span>
</a>
@@ -758,6 +761,9 @@
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('page')">
<span>{{ fd_icon("file",16) }}</span><span>Page</span>
</button>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('template')">
<span>{{ fd_icon("copy",16) }}</span><span>From template…</span>
</button>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('chat')">
<span>{{ fd_icon("message-square",16) }}</span><span>Chat</span>
</button>
@@ -1165,105 +1171,189 @@
},
/**
* Create a new page: opens the v5.12.0 template picker (built-in + the
* user's saved templates), then instantiates the chosen one.
* Create a new page: **blank by default** (Notion behaviour — the "New"
* button creates an empty document immediately). Template-based creation
* lives in {@link createPageFromTemplate}.
*/
createPage: function(parentId) {
var self = this;
// Explicit folder (row/context-menu button) wins; otherwise create in the
// folder the user is currently browsing on the local-workspace page.
if (parentId == null || parentId === '') parentId = this._currentFolderId();
this._createParentId = parentId || 0;
// Direct creation path (fallback / no-template choice).
this._createPlainPage = function() {
var ctx = self._getContext();
if (ctx.wsId > 0) {
var body = { name: '', type: 'page' };
if (ctx.isGitea) body.workspace_id = ctx.wsId;
if (self._createParentId) body.parent_id = self._createParentId;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': self.getCsrfToken() },
body: JSON.stringify(body)
})
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
this._createPlainPage();
},
/** Create an empty page and navigate to it. */
_createPlainPage: function() {
var self = this;
var ctx = this._getContext();
if (ctx.wsId > 0) {
var body = { name: '', type: 'page' };
if (ctx.isGitea) body.workspace_id = ctx.wsId;
if (this._createParentId) body.parent_id = this._createParentId;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify(body)
})
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
return;
}
var project = ctx.workspaceKey;
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-Token': this.getCsrfToken() }
})
.then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); });
},
/** Open the unified template picker (registry-backed) to create from a template. */
createPageFromTemplate: function(parentId, kind) {
if (parentId == null || parentId === '') parentId = this._currentFolderId();
this._createParentId = parentId || 0;
this._openTemplatePicker(kind);
},
/**
* Unified template picker (registre `templates`, §7.1) — self-contained DOM
* modal usable on ANY page (sidebar, workspace local, library). Always shows
* a visible state (list / empty / error) and a working Cancel + ✕ (P5).
*/
_openTemplatePicker: function(kindFilter) {
var self = this;
var existing = document.getElementById('fd-global-tpl-picker');
if (existing) existing.remove();
var state = { kind: kindFilter || 'page', query: '', items: [], selected: null };
var ov = document.createElement('div');
ov.id = 'fd-global-tpl-picker';
ov.className = 'fd-gtpl-overlay open';
ov.innerHTML =
'<div class="fd-gtpl" role="dialog" aria-label="Nouveau depuis un template">'
+ '<div class="fd-gtpl-head"><span class="fd-gtpl-title">Nouveau depuis un template</span>'
+ '<button type="button" class="fd-gtpl-x" data-gtpl="close" aria-label="Fermer">&times;</button></div>'
+ '<div class="fd-gtpl-body">'
+ '<input type="text" class="fd-gtpl-search" data-gtpl="search" placeholder="🔍 Rechercher un template…">'
+ '<div class="fd-gtpl-filters" data-gtpl="filters"></div>'
+ '<div class="fd-gtpl-list" data-gtpl="list"><div class="fd-gtpl-empty">Chargement…</div></div>'
+ '</div>'
+ '<div class="fd-gtpl-foot">'
+ '<button type="button" class="btn-sm" data-gtpl="cancel" style="padding:8px 16px">Annuler</button>'
+ '<button type="button" class="btn-sm" data-gtpl="use" style="padding:8px 20px;background:var(--accent);color:#fff;border:none;font-weight:600;cursor:pointer" disabled>Utiliser</button>'
+ '</div></div>';
document.body.appendChild(ov);
var KINDS = [
{ k: 'page', label: 'Pages' },
{ k: 'database', label: 'Bases' },
{ k: 'row', label: 'Lignes' },
{ k: 'blocks', label: 'Blocs' },
{ k: '', label: 'Tous' },
];
var labels = { page: 'Page', row: 'Ligne', database: 'Base', blocks: 'Blocs' };
function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
var elFilters = ov.querySelector('[data-gtpl="filters"]');
var elList = ov.querySelector('[data-gtpl="list"]');
var elSearch = ov.querySelector('[data-gtpl="search"]');
var elUse = ov.querySelector('[data-gtpl="use"]');
function renderFilters() {
elFilters.innerHTML = KINDS.map(function(k) {
var active = state.kind === k.k ? ' active' : '';
return '<button type="button" class="fd-tpl-filter' + active + '" data-kind="' + k.k + '">' + k.label + '</button>';
}).join('') + '<a href="/templates" style="margin-left:auto;align-self:center;font-size:12px;color:var(--text-dim);text-decoration:none;">Gérer…</a>';
elFilters.querySelectorAll('[data-kind]').forEach(function(b) {
b.onclick = function() { state.kind = b.getAttribute('data-kind'); renderFilters(); load(); };
});
}
function renderList() {
if (!state.items.length) {
elList.innerHTML = '<div class="fd-gtpl-empty">Aucun template pour ce filtre — <a href="/templates">créez-en un</a>.</div>';
return;
}
var project = ctx.workspaceKey;
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-Token': self.getCsrfToken() }
})
.then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); });
};
this._openTemplatePicker();
elList.innerHTML = state.items.map(function(t, i) {
var sel = state.selected && state.selected.id === t.id ? ' sel' : '';
return '<div class="fd-gtpl-card' + sel + '" data-idx="' + i + '">'
+ '<span class="fd-gtpl-ico">' + esc(t.icon || '📄') + '</span>'
+ '<span style="flex:1;min-width:0;"><span class="fd-gtpl-name">' + esc(t.name) + '</span>'
+ '<div class="fd-gtpl-meta">' + esc((labels[t.kind] || t.kind) + ' · ' + t.scope) + '</div></span></div>';
}).join('');
elList.querySelectorAll('[data-idx]').forEach(function(card) {
card.onclick = function() {
state.selected = state.items[parseInt(card.getAttribute('data-idx'), 10)];
elUse.disabled = false;
renderList();
};
});
}
function load() {
elList.innerHTML = '<div class="fd-gtpl-empty">Chargement…</div>';
var p = new URLSearchParams();
if (state.kind) p.set('kind', state.kind);
if (state.query) p.set('q', state.query);
fetch('/api/templates?' + p.toString(), { credentials: 'same-origin' })
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function(d) { state.items = d.templates || []; renderList(); })
.catch(function(e) {
elList.innerHTML = '<div class="fd-gtpl-empty" style="color:var(--danger)">Chargement impossible : ' + esc(e.message)
+ ' <button type="button" class="btn-sm" data-retry style="margin-left:8px">Réessayer</button></div>';
var rb = elList.querySelector('[data-retry]');
if (rb) rb.onclick = load;
});
}
function close() { ov.remove(); }
ov.addEventListener('click', function(e) { if (e.target === ov) close(); });
ov.querySelector('[data-gtpl="close"]').onclick = close;
ov.querySelector('[data-gtpl="cancel"]').onclick = close;
document.addEventListener('keydown', function esc(ev) {
if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
});
elSearch.oninput = function() { state.query = elSearch.value; };
elSearch.onkeydown = function(ev) { if (ev.key === 'Enter') load(); };
elUse.onclick = function() { self._applyGlobalTemplate(state.selected, elUse, close); };
renderFilters();
load();
setTimeout(function() { elSearch.focus(); }, 50);
},
_openTemplatePicker: function() {
_applyGlobalTemplate: function(t, btn, close) {
if (!t) return;
var self = this;
function escAttr(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
if (document.getElementById('fd-tpl-picker')) { document.getElementById('fd-tpl-picker').style.display = 'flex'; return; }
var ov = document.createElement('div');
ov.id = 'fd-tpl-picker';
ov.className = 'tpl-picker-overlay';
ov.innerHTML = '<div class="tpl-picker-modal"><div class="tpl-picker-title">New page from template</div>'
+ '<div class="tpl-picker-sub">Pick a starting point — or start blank.</div>'
+ '<div class="tpl-grid" id="fd-tpl-grid"><div style="color:var(--text-dim);font-size:13px;padding:12px">Loading…</div></div>'
+ '<div style="text-align:right;margin-top:14px"><button class="btn-sm" id="fd-tpl-close" style="cursor:pointer;padding:6px 14px">Cancel</button></div></div>';
document.body.appendChild(ov);
ov.addEventListener('click', function(e){ if(e.target===ov) ov.style.display='none'; });
document.getElementById('fd-tpl-close').onclick = function(){ ov.style.display='none'; };
fetch('/board/api/page-templates', {credentials:'same-origin'})
.then(function(r){ return r.json(); })
.then(function(d){
var list = d.templates || [];
var h = '';
list.forEach(function(t){
var use = t.builtin
? "key:"+t.key
: "id:"+t.id;
h += '<div class="tpl-card" data-use="'+escAttr(use)+'">'
+ '<span class="tpl-card-icon">'+escAttr(t.icon||'📄')+'</span>'
+ '<span><span class="tpl-card-name">'+escAttr(t.name)+'</span>'
+ '<div class="tpl-card-desc">'+escAttr(t.description||'')+'</div></span></div>';
});
document.getElementById('fd-tpl-grid').innerHTML = h || '<div style="color:var(--text-dim)">No templates</div>';
document.querySelectorAll('#fd-tpl-grid .tpl-card').forEach(function(card){
card.onclick = function(){ self._useTemplate(card.dataset.use); };
});
}).catch(function(){ document.getElementById('fd-tpl-grid').innerHTML='<div style="color:var(--text-dim)">Failed to load templates</div>'; });
},
_useTemplate: function(use){
var ov = document.getElementById('fd-tpl-picker');
if (ov) ov.style.display = 'none';
var self = this;
if (!use || use === 'key:empty') { this._createPlainPage(); return; }
var parts = use.split(':');
var body = {};
var url;
// Pass the current workspace context so the created page is persisted
// in the active local/Gitea workspace (mirrors _createPlainPage()).
var ctx = self._getContext();
if (ctx.wsId > 0) { body.workspace_id = ctx.wsId; }
else if (ctx.workspaceKey) { body.workspace = ctx.workspaceKey; }
if (self._createParentId) { body.parent_id = self._createParentId; }
if (parts[0] === 'key') { body.key = parts.slice(1).join(':'); url = '/board/api/page-templates/0/use'; }
else { url = '/board/api/page-templates/' + parts[1] + '/use'; }
fetch(url, {
var ctx = this._getContext();
var context = {};
if (this._createParentId) context.parent_id = this._createParentId;
if (ctx.wsId > 0) context.workspace_id = ctx.wsId;
if (btn) { btn.disabled = true; btn.textContent = 'Application…'; }
fetch('/api/templates/' + encodeURIComponent(t.id) + '/instantiate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify(body)
body: JSON.stringify({ variables: {}, context: context })
})
.then(function(r){ if (!r.ok) throw new Error('Template failed'); return r.json(); })
.then(function(d){ window.location.href = '/pages/' + d.id; })
.catch(function(){ self._createPlainPage(); });
.then(function(r) {
if (!r.ok) return r.text().then(function(txt) { throw new Error('HTTP ' + r.status + ' ' + txt.slice(0, 160)); });
return r.json();
})
.then(function(d) {
(d.warnings || []).forEach(function(w) { self._toast(w, 'error'); });
if (close) close();
if (d.url) {
if (window.fdNavigate) window.fdNavigate(d.url); else window.location.href = d.url;
} else {
self._toast('Créé depuis « ' + t.name + ' »', 'success');
}
})
.catch(function(e) {
if (btn) { btn.disabled = false; btn.textContent = 'Utiliser'; }
self._toast('Échec : ' + e.message, 'error');
});
},
/** Show the global New Folder modal (parentId = target folder, optional). */
@@ -1836,6 +1926,8 @@
this.newMenuOpen = false;
if (kind === 'page') {
if (window.FlowDeck && window.FlowDeck.createPage) window.FlowDeck.createPage();
} else if (kind === 'template') {
if (window.FlowDeck && window.FlowDeck.createPageFromTemplate) window.FlowDeck.createPageFromTemplate();
} else if (kind === 'chat') {
if (typeof this.chatNewConversation === 'function') this.chatNewConversation();
else if (typeof this.agentNew === 'function') this.agentNew();
@@ -1843,8 +1935,11 @@
if (typeof this.fdCreateMeeting === 'function') this.fdCreateMeeting();
else if (typeof this.meetingNote === 'function') this.meetingNote();
} else if (kind === 'database') {
if (typeof this.createDatabase === 'function') this.createDatabase();
else if (window.showToast) window.showToast('Database creation: use a Gitea repo as a database.', 'info');
if (window.FlowDeck && window.FlowDeck.createPageFromTemplate) {
window.FlowDeck.createPageFromTemplate(null, 'database');
} else if (typeof this.createDatabase === 'function') {
this.createDatabase();
}
}
},
// ── Sidebar customization ──