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:
@@ -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 —');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user