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:
+183
-88
@@ -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">×</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||
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 ──
|
||||
|
||||
Reference in New Issue
Block a user