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
+186
View File
@@ -0,0 +1,186 @@
// T0-3 — Régression du bouton « Templates » (page vide).
// Avant correctif : la pilule « Templates » basculait `gsMoreOpen` (même flag
// que « ••• ») et ouvrait le menu de création de base — aucun sélecteur.
// Après : le clic ouvre TOUJOURS le sélecteur unifié (liste, état vide ou
// erreur explicite — jamais un no-op silencieux, P5).
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`);
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
async function createEmptyPage(page) {
return await page.evaluate(async () => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'Tpl regression ' + Date.now(), type: 'page' }),
});
return await r.json();
});
}
async function setupWorkspace(page, name) {
return await page.evaluate(async (n) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: n }),
});
const ws = await r.json();
await fetch(`/api/workspaces/${ws.id}/select`, { method: 'POST', headers: { 'X-CSRF-Token': csrf } });
return ws;
}, name);
}
test.describe('Templates picker (T0-3)', () => {
test('le clic sur la pilule Templates ouvre le sélecteur', async ({ page }) => {
await login(page);
const created = await createEmptyPage(page);
await page.goto(`${FD_BASE}/pages/${created.id}`);
await page.waitForSelector('#_blocksCt', { timeout: 15000 });
// La rangée « Get started with » est visible sur une page vide.
const pill = page.locator('.get-started-toolbar .gs-pill', { hasText: 'Templates' });
await expect(pill).toBeVisible({ timeout: 10000 });
await pill.click();
// Le sélecteur unifié s'ouvre — liste, état vide OU erreur : jamais rien.
const picker = page.getByTestId('tpl-picker');
await expect(picker).toBeVisible({ timeout: 10000 });
// Le catalogue contient les presets système (ou un état vide explicite).
const items = page.getByTestId('tpl-item');
const n = await items.count();
if (n === 0) {
await expect(picker.getByText(/Aucun template|Chargement impossible/)).toBeVisible();
} else {
// Choisir le premier template et l'appliquer à la page courante.
await items.first().click();
await page.getByTestId('tpl-apply').click();
// Effet visible : toast de confirmation (la page se remplit en place).
await expect(page.locator('.sd-toast, .toast').first()).toBeVisible({ timeout: 15000 });
}
});
test('le gestionnaire /templates liste le catalogue', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/templates`);
await expect(page.getByRole('heading', { name: 'Templates' })).toBeVisible({ timeout: 15000 });
// Au moins les presets système, même pour un utilisateur non-admin.
await expect(page.locator('.lib-container').getByText(/Note de réunion|Revue hebdo|Journal quotidien/).first()).toBeVisible({ timeout: 15000 });
});
test('les filtres Tous/page/blocks/row/database filtrent la liste', async ({ page }) => {
await login(page);
const created = await createEmptyPage(page);
await page.goto(`${FD_BASE}/pages/${created.id}`);
await page.waitForSelector('#_blocksCt', { timeout: 15000 });
await page.locator('.get-started-toolbar .gs-pill', { hasText: 'Templates' }).click();
await page.getByTestId('tpl-picker').waitFor({ state: 'visible', timeout: 10000 });
// Filtre « Bases » : ne liste que des bases.
await page.locator('#fd-tpl-picker button', { hasText: /^Bases$/ }).click();
await expect(page.getByTestId('tpl-item').first()).toContainText('database', { timeout: 10000 });
// Filtre « Pages » : ne liste que des pages.
await page.locator('#fd-tpl-picker button', { hasText: /^Pages$/ }).click();
await expect(page.getByTestId('tpl-item').first()).toContainText('page', { timeout: 10000 });
});
test('un template Database s’insère dans le document courant', async ({ page }) => {
await login(page);
const created = await createEmptyPage(page);
await page.goto(`${FD_BASE}/pages/${created.id}`);
await page.waitForSelector('#_blocksCt', { timeout: 15000 });
await page.locator('.get-started-toolbar .gs-pill', { hasText: 'Templates' }).click();
await page.getByTestId('tpl-picker').waitFor({ state: 'visible', timeout: 10000 });
await page.locator('#fd-tpl-picker button', { hasText: /^Bases$/ }).click();
await page.getByTestId('tpl-item').first().click();
await page.getByTestId('tpl-apply').click();
// Effet visible sans navigation : la base est insérée dans le document.
await expect(page.locator('.sd-toast, .toast').first()).toContainText(/insérée|créé/i, { timeout: 15000 });
expect(page.url()).toContain(`/pages/${created.id}`);
});
test('le bouton « New page » crée un document vide (pas le panneau template)', async ({ page }) => {
await login(page);
await setupWorkspace(page, 'NewPage ' + Date.now());
await page.goto(`${FD_BASE}/local-workspace`);
await page.waitForTimeout(1500);
const btn = page.locator('button[title="New page"]').first();
await expect(btn).toBeVisible({ timeout: 10000 });
await btn.click();
// Création directe → navigation vers l'éditeur, aucun panneau template.
await page.waitForURL('**/pages/**', { timeout: 15000 });
expect(await page.locator('#fd-global-tpl-picker, .tpl-picker-overlay').count()).toBe(0);
});
test('le menu New > From template ouvre le sélecteur unifié', async ({ page }) => {
await login(page);
await setupWorkspace(page, 'NewTpl ' + Date.now());
await page.goto(`${FD_BASE}/local-workspace`);
await page.waitForTimeout(1500);
await page.locator('.fd-new-btn').click();
await page.locator('.fd-new-pop-item', { hasText: /From template/ }).click();
const picker = page.locator('#fd-global-tpl-picker');
await expect(picker).toBeVisible({ timeout: 10000 });
// Fermeture explicite : le ✕ fonctionne (jamais un no-op).
await picker.locator('[data-gtpl="close"]').click();
await expect(picker).toHaveCount(0, { timeout: 5000 });
});
test('la commande / template ouvre le sélecteur sans les pages', async ({ page }) => {
await login(page);
const created = await createEmptyPage(page);
await page.goto(`${FD_BASE}/pages/${created.id}`);
await page.waitForSelector('#_blocksCt .block-content', { timeout: 15000 });
const block = page.locator('#_blocksCt .block-content').first();
await block.click();
await page.waitForTimeout(300);
await page.keyboard.type('/');
await page.waitForSelector('.slash-item', { timeout: 10000 });
await page.keyboard.type('template');
const item = page.locator('.slash-item[data-sid="template"]');
await expect(item).toBeVisible({ timeout: 10000 });
await item.click();
const picker = page.getByTestId('tpl-picker');
await expect(picker).toBeVisible({ timeout: 10000 });
// Filtres : base / ligne / blocs seulement — pas de « Pages ».
const labels = await picker.locator('.fd-tpl-filter').allInnerTexts();
expect(labels).toEqual(expect.arrayContaining(['Bases', 'Lignes', 'Blocs']));
expect(labels).not.toContain('Pages');
// Aucun template de page représenté (genres base/ligne/blocs uniquement).
const texts = await picker.getByTestId('tpl-item').allInnerTexts();
expect(texts.length).toBeGreaterThan(0);
expect(texts.every((t) => !/page ·/.test(t))).toBeTruthy();
});
test('appliquer un template de page règle le titre sans variable', async ({ page }) => {
await login(page);
const created = await createEmptyPage(page);
await page.goto(`${FD_BASE}/pages/${created.id}`);
await page.waitForSelector('#_blocksCt .block-content', { timeout: 15000 });
await page.locator('.get-started-toolbar .gs-pill', { hasText: 'Templates' }).click();
const picker = page.getByTestId('tpl-picker');
await picker.waitFor({ state: 'visible', timeout: 10000 });
await picker.locator('.fd-tpl-filter', { hasText: /^Pages$/ }).click();
await picker.getByTestId('tpl-item').filter({ hasText: 'Compte rendu client' }).first().click();
await page.getByTestId('tpl-apply').click();
// Pas d'échec « variable requise », et le titre suit le template.
const toast = page.locator('.sd-toast, .toast').first();
await expect(toast).toContainText(/remplie depuis/i, { timeout: 15000 });
await expect(page.locator('#_titleEl')).toContainText('CR —');
});
});