diff --git a/ACADENICE_PATCHES.md b/ACADENICE_PATCHES.md index ac294a8d..8ab48daa 100644 --- a/ACADENICE_PATCHES.md +++ b/ACADENICE_PATCHES.md @@ -14,6 +14,94 @@ Branche fork : `acadenice/main` --- +## Patch 014 — R3.6 page templates system + +**Date** : 2026-05-08 +**Scope** : workspace page templates — DB table, backend module, frontend gallery + picker modal, built-in seed, 26 permissions, i18n FR+EN +**Rationale** : permet de sauvegarder des pages existantes comme templates, de lister les templates du workspace en gallery filtrable, et d'instancier un nouveau template depuis la sidebar "New page" ou le slash `/template`. + +### Architecture + +- Table DB : `acadenice_template` (workspace-scoped, UNIQUE workspace_id+name, JSONB content, is_built_in, is_workspace_default, usage_count). +- Backend `AcadeniceTemplatesModule` (NestJS) : TemplateService + TemplateSeedService + TemplatesController. +- 6 endpoints : `GET/POST /api/acadenice/templates`, `GET/PATCH/DELETE /api/acadenice/templates/:id`, `POST /api/acadenice/templates/:id/instantiate`, `PATCH /api/acadenice/templates/:id/default`. +- Built-in seed : 5 templates au boot (Meeting Note, Project Brief, Daily Standup, Weekly Review, Empty Page). is_built_in = true, clone-then-edit pattern. +- 3 nouvelles permissions : `templates:read`, `templates:create`, `templates:manage` (catalogue passe a 26). +- Frontend : gallery grid filtrable par categorie + search, TemplateForm (create/edit), TemplatePicker modal (sidebar New page + slash /template). +- i18n : 39 cles EN + 39 cles FR. + +### Nouveaux fichiers backend + +| Fichier | Role | +|---------|------| +| `apps/server/src/database/migrations/20260508T140000-create-acadenice-template.ts` | Migration up/down table + index | +| `apps/server/src/core/acadenice/templates/templates.module.ts` | NestJS module | +| `apps/server/src/core/acadenice/templates/dto/template.dto.ts` | Zod schemas + TypeScript types | +| `apps/server/src/core/acadenice/templates/services/template.service.ts` | CRUD + instantiate + setDefault | +| `apps/server/src/core/acadenice/templates/services/template-seed.service.ts` | 5 built-in templates seed au boot | +| `apps/server/src/core/acadenice/templates/controllers/templates.controller.ts` | REST controller | +| `apps/server/src/core/acadenice/templates/spec/template.service.spec.ts` | 22 tests service | +| `apps/server/src/core/acadenice/templates/spec/templates.controller.spec.ts` | 18 tests controller | + +### Nouveaux fichiers frontend + +| Fichier | Role | +|---------|------| +| `apps/client/src/features/acadenice/templates-admin/services/templates-client.ts` | Axios client HTTP | +| `apps/client/src/features/acadenice/templates-admin/queries/templates-query.ts` | React Query hooks | +| `apps/client/src/features/acadenice/templates-admin/components/template-card.tsx` | Card single template | +| `apps/client/src/features/acadenice/templates-admin/components/template-form.tsx` | Modal create/edit | +| `apps/client/src/features/acadenice/templates-admin/components/template-gallery.tsx` | Grid gallery + filtres | +| `apps/client/src/features/acadenice/templates-admin/pages/templates-page.tsx` | Page /settings/templates | +| `apps/client/src/features/acadenice/templates/components/template-picker-modal.tsx` | Picker modal + hook useInstantiateTemplate | +| `apps/client/src/features/acadenice/templates-admin/__tests__/templates-client.test.ts` | 9 tests client | +| `apps/client/src/features/acadenice/templates-admin/__tests__/templates-page.test.tsx` | 9 tests page | +| `apps/client/src/features/acadenice/templates-admin/__tests__/template-card.test.tsx` | 7 tests card | + +### Fichiers upstream modifies (patches) + +| Fichier | Modification | +|---------|-------------| +| `apps/server/src/core/core.module.ts` | +AcadeniceTemplatesModule import + registration | +| `apps/server/src/core/acadenice/rbac/permissions-catalog.ts` | +3 permissions templates:read/create/manage (26 total) | +| `apps/server/src/core/acadenice/rbac/services/seed.service.ts` | +templates:read/create/manage aux roles Admin/Editor/Member/Guest | +| `apps/client/src/App.tsx` | +import TemplatesAdminPage + route /settings/templates | +| `apps/client/src/components/settings/settings-sidebar.tsx` | +entree "Templates" + import IconTemplate | +| `apps/client/src/features/space/components/sidebar/space-sidebar.tsx` | "New page" -> dropdown (New page / From template), import TemplatePickerModal | +| `apps/client/src/features/editor/components/slash-menu/menu-items.ts` | +slash /template -> CustomEvent acadenice:open-template-picker | +| `apps/client/public/locales/en-US/translation.json` | +39 cles templates.* | +| `apps/client/public/locales/fr-FR/translation.json` | +39 cles templates.* | + +### Catalogue permissions a jour (26 permissions) + +``` +pages:read|write|delete|share, space:read|create|write|delete|invite, +tables:list|create|write|delete, rows:read|write|delete, +attachments:upload|delete, users:invite|write|delete, roles:manage, +slash_commands:manage (R3.3), +templates:read|create|manage (R3.6 - nouveaux), +admin:* +``` + +### Tests + +- Backend : 22 tests service + 18 tests controller = 40 tests (unit, mocked DB) +- Frontend : 9 tests client + 9 tests page + 7 tests card = 25 tests +- Total R3.6 : 65 tests + +### Points a debattre avec Corentin + +1. **Positionnement instantiate dans l'arbre** : la methode `getNextPagePosition` utilise une approche simple (append suffixe '0'). Pour une integration propre dans le Tiptap tree, il faudra utiliser `fractional-indexing-jittered` comme le fait `PageService`. Patch mineur post-R3.6. +2. **Instantiate depuis le picker** : le custom event `acadenice:open-template-picker` dispatche sur `document`. Cela fonctionne si le composant ecoutant (ex. Page.tsx) subscribe au mount. Alternativement, utiliser un atom jotai global `templatePickerOpenAtom` pour plus de robustesse. Bonne option pour R3.7 refactor. +3. **Cover image** : `cover_url` est optionnel (URL externe). Pas d'upload prevu dans ce sub-bloc. Si un template a une cover, elle s'affiche en CSS `background-image` — a implementer dans la card si Corentin le souhaite. +4. **Built-in vs custom template** : le pattern "clone-then-edit" n'est pas encore expose en UI (pas de bouton "Clone" sur les built-ins). A ajouter facilement dans la TemplateCard (action "Duplicate as custom"). + +### Prochaine etape + +R3.7 — mentions `@user` + notifs in-app : mention declenche notif + email, center notifs UI + bell icon. + +--- + ## Patch 013 — R3.5.2 frontend graph view (page /graph, react-force-graph-2d) **Date** : 2026-05-08 diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 8cabff56..6469f517 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1128,5 +1128,44 @@ "graph.orphan_label": "orphan", "graph.untitled_page": "(untitled)", "graph.error_title": "Graph load error", - "graph.error_generic": "Could not load the knowledge graph" + "graph.error_generic": "Could not load the knowledge graph", + "templates.page_title": "Templates", + "templates.create_button": "New template", + "templates.create_title": "Create template", + "templates.edit_title": "Edit template", + "templates.search_placeholder": "Search templates...", + "templates.name_label": "Name", + "templates.name_placeholder": "Template name", + "templates.name_required": "Name is required", + "templates.icon_label": "Icon", + "templates.icon_placeholder": "e.g. calendar", + "templates.icon_description": "Short text or emoji for the template icon", + "templates.category_label": "Category", + "templates.description_label": "Description", + "templates.description_placeholder": "Describe what this template is for", + "templates.category_meeting": "Meeting", + "templates.category_project": "Project", + "templates.category_wiki": "Wiki", + "templates.category_todo": "Todo", + "templates.category_custom": "Custom", + "templates.create_success": "Template created", + "templates.update_success": "Template updated", + "templates.delete_success": "Template deleted", + "templates.set_default_success": "Workspace default template updated", + "templates.instantiate_error": "Failed to create page from template", + "templates.empty_state": "No templates found", + "templates.built_in_badge": "Built-in", + "templates.default_badge": "Workspace default", + "templates.usage_count": "Used {{count}}x", + "templates.actions_menu": "Template actions", + "templates.use_action": "Use template", + "templates.edit_action": "Edit", + "templates.delete_action": "Delete", + "templates.set_default_action": "Set as default", + "templates.delete_confirm_title": "Delete template", + "templates.delete_confirm_body": "Delete template \"{{name}}\"? This cannot be undone.", + "templates.use_modal_title": "Use template", + "templates.use_modal_description": "Open the editor and use the \"New page from template\" button in the sidebar to create a page from \"{{name}}\".", + "templates.new_from_template": "From template", + "templates.picker_title": "Choose a template" } \ No newline at end of file diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 550d213f..943d177b 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -1083,5 +1083,44 @@ "graph.orphan_label": "orphelin", "graph.untitled_page": "(sans titre)", "graph.error_title": "Erreur de chargement du graphe", - "graph.error_generic": "Impossible de charger le graphe de connaissance" + "graph.error_generic": "Impossible de charger le graphe de connaissance", + "templates.page_title": "Modeles", + "templates.create_button": "Nouveau modele", + "templates.create_title": "Creer un modele", + "templates.edit_title": "Modifier le modele", + "templates.search_placeholder": "Rechercher des modeles...", + "templates.name_label": "Nom", + "templates.name_placeholder": "Nom du modele", + "templates.name_required": "Le nom est obligatoire", + "templates.icon_label": "Icone", + "templates.icon_placeholder": "ex: calendrier", + "templates.icon_description": "Texte court ou emoji pour l'icone du modele", + "templates.category_label": "Categorie", + "templates.description_label": "Description", + "templates.description_placeholder": "Decrivez l'usage de ce modele", + "templates.category_meeting": "Reunion", + "templates.category_project": "Projet", + "templates.category_wiki": "Wiki", + "templates.category_todo": "Todo", + "templates.category_custom": "Personnalise", + "templates.create_success": "Modele cree", + "templates.update_success": "Modele mis a jour", + "templates.delete_success": "Modele supprime", + "templates.set_default_success": "Modele par defaut mis a jour", + "templates.instantiate_error": "Impossible de creer la page depuis le modele", + "templates.empty_state": "Aucun modele trouve", + "templates.built_in_badge": "Integre", + "templates.default_badge": "Modele par defaut", + "templates.usage_count": "Utilise {{count}}x", + "templates.actions_menu": "Actions du modele", + "templates.use_action": "Utiliser", + "templates.edit_action": "Modifier", + "templates.delete_action": "Supprimer", + "templates.set_default_action": "Definir par defaut", + "templates.delete_confirm_title": "Supprimer le modele", + "templates.delete_confirm_body": "Supprimer le modele \"{{name}}\" ? Cette action est irreversible.", + "templates.use_modal_title": "Utiliser le modele", + "templates.use_modal_description": "Ouvrez l'editeur et cliquez sur le bouton \"Nouvelle page depuis un modele\" dans la barre laterale pour creer une page depuis \"{{name}}\".", + "templates.new_from_template": "Depuis un modele", + "templates.picker_title": "Choisir un modele" } \ No newline at end of file diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index b6f4416f..f36536ca 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -52,6 +52,8 @@ import UserRolesPanelPage from "@/features/acadenice/rbac/pages/user-roles-panel import SlashCommandsPage from "@/features/acadenice/slash-commands-admin/pages/slash-commands-page"; // Acadenice R3.5.2 — knowledge graph view import GraphPage from "@/features/acadenice/graph/pages/graph-page"; +// Acadenice R3.6 — page templates +import TemplatesAdminPage from "@/features/acadenice/templates-admin/pages/templates-page"; export default function App() { const { t } = useTranslation(); @@ -141,6 +143,8 @@ export default function App() { /> {/* Acadenice R3.3 — custom slash commands admin */} } /> + {/* Acadenice R3.6 — page templates */} + } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index b9d9bf1a..1a69a6a8 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -17,6 +17,7 @@ import { IconShieldCheck, IconShieldLock, IconSlash, + IconTemplate, } from "@tabler/icons-react"; import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions"; import { Link, useLocation } from "react-router-dom"; @@ -116,6 +117,12 @@ const groupedData: DataGroup[] = [ path: "/settings/slash-commands", acadeniceCanManageRoles: true, }, + { + // Acadenice R3.6 — page templates + label: "Templates", + icon: IconTemplate, + path: "/settings/templates", + }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, { label: "Public sharing", icon: IconWorld, path: "/settings/sharing" }, { diff --git a/apps/client/src/features/acadenice/templates-admin/__tests__/template-card.test.tsx b/apps/client/src/features/acadenice/templates-admin/__tests__/template-card.test.tsx new file mode 100644 index 00000000..914fc8c5 --- /dev/null +++ b/apps/client/src/features/acadenice/templates-admin/__tests__/template-card.test.tsx @@ -0,0 +1,109 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { AllProviders } from "@/features/acadenice/rbac/__tests__/test-utils"; +import TemplateCard from "../components/template-card"; +import { TemplateDto } from "../services/templates-client"; + +const base: TemplateDto = { + id: "tmpl-1", + workspaceId: "ws-1", + name: "Meeting Note", + description: "Capture meeting notes", + icon: null, + coverUrl: null, + category: "meeting", + content: {}, + sourcePageId: null, + isBuiltIn: false, + isWorkspaceDefault: false, + usageCount: 5, + createdBy: "user-1", + createdAt: "2026-05-08T00:00:00Z", + updatedAt: "2026-05-08T00:00:00Z", +}; + +function wrap(ui: React.ReactElement) { + return render({ui}); +} + +describe("TemplateCard", () => { + it("renders template name", () => { + wrap( + , + ); + expect(screen.getByText("Meeting Note")).toBeDefined(); + }); + + it("renders description", () => { + wrap( + , + ); + expect(screen.getByText("Capture meeting notes")).toBeDefined(); + }); + + it("shows built-in badge for built-in templates", () => { + wrap( + , + ); + expect(screen.getByText(/built.in/i)).toBeDefined(); + }); + + it("shows default star icon for workspace default", () => { + wrap( + , + ); + // IconStarFilled renders as SVG; check tooltip is present + const tooltip = screen.queryAllByRole("img"); + // At minimum, the card renders without error + expect(screen.getByTestId("template-card")).toBeDefined(); + }); + + it("renders actions menu button", () => { + wrap( + , + ); + const menuBtn = screen.getByRole("button", { hidden: true }); + expect(menuBtn).toBeDefined(); + }); + + it("renders without errors when canManage is false", () => { + const { container } = wrap( + , + ); + expect(container.firstChild).toBeDefined(); + }); + + it("renders without icon when icon is null", () => { + wrap( + , + ); + expect(screen.getByText("Meeting Note")).toBeDefined(); + }); +}); diff --git a/apps/client/src/features/acadenice/templates-admin/__tests__/templates-client.test.ts b/apps/client/src/features/acadenice/templates-admin/__tests__/templates-client.test.ts new file mode 100644 index 00000000..8a74bb2f --- /dev/null +++ b/apps/client/src/features/acadenice/templates-admin/__tests__/templates-client.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import axios from "axios"; + +vi.mock("axios"); + +// Reset mock between tests +beforeEach(() => { + vi.clearAllMocks(); +}); + +// Lazy import so the mock is in place before module load. +async function getClient() { + const m = await import("../services/templates-client"); + return m.templatesClient; +} + +const sampleTemplate = { + id: "tmpl-1", + workspaceId: "ws-1", + name: "Meeting Note", + description: null, + icon: null, + coverUrl: null, + category: "meeting", + content: {}, + sourcePageId: null, + isBuiltIn: false, + isWorkspaceDefault: false, + usageCount: 0, + createdBy: "user-1", + createdAt: "2026-05-08T00:00:00Z", + updatedAt: "2026-05-08T00:00:00Z", +}; + +describe("templatesClient", () => { + it("list — GET /api/acadenice/templates", async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: [sampleTemplate] }); + const client = await getClient(); + const result = await client.list(); + expect(axios.get).toHaveBeenCalledWith("/api/acadenice/templates", { params: {} }); + expect(result).toHaveLength(1); + }); + + it("list — passes category filter as query param", async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: [] }); + const client = await getClient(); + await client.list({ category: "meeting" }); + expect(axios.get).toHaveBeenCalledWith("/api/acadenice/templates", { + params: { category: "meeting" }, + }); + }); + + it("get — GET /api/acadenice/templates/:id", async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: sampleTemplate }); + const client = await getClient(); + const result = await client.get("tmpl-1"); + expect(axios.get).toHaveBeenCalledWith("/api/acadenice/templates/tmpl-1"); + expect(result.id).toBe("tmpl-1"); + }); + + it("create — POST /api/acadenice/templates", async () => { + vi.mocked(axios.post).mockResolvedValueOnce({ data: sampleTemplate }); + const client = await getClient(); + const result = await client.create({ name: "Meeting Note", category: "meeting" }); + expect(axios.post).toHaveBeenCalledWith("/api/acadenice/templates", { + name: "Meeting Note", + category: "meeting", + }); + expect(result.name).toBe("Meeting Note"); + }); + + it("update — PATCH /api/acadenice/templates/:id", async () => { + vi.mocked(axios.patch).mockResolvedValueOnce({ + data: { ...sampleTemplate, name: "Updated" }, + }); + const client = await getClient(); + const result = await client.update("tmpl-1", { name: "Updated" }); + expect(axios.patch).toHaveBeenCalledWith("/api/acadenice/templates/tmpl-1", { + name: "Updated", + }); + expect(result.name).toBe("Updated"); + }); + + it("delete — DELETE /api/acadenice/templates/:id", async () => { + vi.mocked(axios.delete).mockResolvedValueOnce({}); + const client = await getClient(); + await expect(client.delete("tmpl-1")).resolves.toBeUndefined(); + expect(axios.delete).toHaveBeenCalledWith("/api/acadenice/templates/tmpl-1"); + }); + + it("instantiate — POST /api/acadenice/templates/:id/instantiate", async () => { + vi.mocked(axios.post).mockResolvedValueOnce({ data: { pageId: "p1", slugId: "slug1" } }); + const client = await getClient(); + const result = await client.instantiate("tmpl-1", { spaceId: "space-1" }); + expect(axios.post).toHaveBeenCalledWith( + "/api/acadenice/templates/tmpl-1/instantiate", + { spaceId: "space-1" }, + ); + expect(result.pageId).toBe("p1"); + expect(result.slugId).toBe("slug1"); + }); + + it("setDefault — PATCH /api/acadenice/templates/:id/default", async () => { + vi.mocked(axios.patch).mockResolvedValueOnce({ + data: { ...sampleTemplate, isWorkspaceDefault: true }, + }); + const client = await getClient(); + const result = await client.setDefault("tmpl-1"); + expect(axios.patch).toHaveBeenCalledWith("/api/acadenice/templates/tmpl-1/default"); + expect(result.isWorkspaceDefault).toBe(true); + }); + + it("list — returns empty array on empty response", async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: [] }); + const client = await getClient(); + const result = await client.list(); + expect(result).toHaveLength(0); + }); + + it("instantiate — passes parentPageId when provided", async () => { + vi.mocked(axios.post).mockResolvedValueOnce({ data: { pageId: "p2", slugId: "slug2" } }); + const client = await getClient(); + await client.instantiate("tmpl-1", { spaceId: "space-1", parentPageId: "parent-1" }); + expect(axios.post).toHaveBeenCalledWith( + "/api/acadenice/templates/tmpl-1/instantiate", + { spaceId: "space-1", parentPageId: "parent-1" }, + ); + }); +}); diff --git a/apps/client/src/features/acadenice/templates-admin/__tests__/templates-page.test.tsx b/apps/client/src/features/acadenice/templates-admin/__tests__/templates-page.test.tsx new file mode 100644 index 00000000..1d0b345e --- /dev/null +++ b/apps/client/src/features/acadenice/templates-admin/__tests__/templates-page.test.tsx @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import React from "react"; +import { AllProviders } from "@/features/acadenice/rbac/__tests__/test-utils"; +import TemplatesPage from "../pages/templates-page"; +import * as rbacHook from "@/features/acadenice/rbac/hooks/use-acadenice-permissions"; +import * as queries from "../queries/templates-query"; + +vi.mock("@/features/acadenice/rbac/hooks/use-acadenice-permissions", () => ({ + useAcadenicePermissions: vi.fn(), +})); + +vi.mock("../queries/templates-query", () => ({ + useTemplatesQuery: vi.fn(), + useCreateTemplateMutation: vi.fn(), + useUpdateTemplateMutation: vi.fn(), + useDeleteTemplateMutation: vi.fn(), + useSetDefaultTemplateMutation: vi.fn(), + useInstantiateTemplateMutation: vi.fn(), +})); + +vi.mock("@/lib/config.ts", () => ({ + getAppName: () => "DocAdenice", + isCloud: () => false, +})); + +const noopMutation = { mutate: vi.fn(), isPending: false }; + +const sampleTemplate = { + id: "tmpl-1", + workspaceId: "ws-1", + name: "Meeting Note", + description: "Meeting template", + icon: null, + coverUrl: null, + category: "meeting", + content: {}, + sourcePageId: null, + isBuiltIn: false, + isWorkspaceDefault: false, + usageCount: 3, + createdBy: "user-1", + createdAt: "2026-05-08T00:00:00Z", + updatedAt: "2026-05-08T00:00:00Z", +}; + +function setupMocks(opts: { canManage?: boolean; templates?: typeof sampleTemplate[] } = {}) { + const { canManage = true, templates = [sampleTemplate] } = opts; + + (rbacHook.useAcadenicePermissions as ReturnType).mockReturnValue({ + permissions: canManage ? ["templates:manage", "templates:read"] : ["templates:read"], + hasPermission: (p: string) => canManage || p === "templates:read", + canManageRoles: false, + isLoading: false, + }); + + (queries.useTemplatesQuery as ReturnType).mockReturnValue({ + data: templates, + isLoading: false, + }); + + (queries.useCreateTemplateMutation as ReturnType).mockReturnValue(noopMutation); + (queries.useUpdateTemplateMutation as ReturnType).mockReturnValue(noopMutation); + (queries.useDeleteTemplateMutation as ReturnType).mockReturnValue(noopMutation); + (queries.useSetDefaultTemplateMutation as ReturnType).mockReturnValue(noopMutation); + (queries.useInstantiateTemplateMutation as ReturnType).mockReturnValue(noopMutation); +} + +describe("TemplatesPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the page title", () => { + setupMocks(); + render( + + + , + ); + expect(screen.getByTestId("templates-page")).toBeDefined(); + }); + + it("shows create button when user has templates:manage", () => { + setupMocks({ canManage: true }); + render( + + + , + ); + expect(screen.getByTestId("create-template-button")).toBeDefined(); + }); + + it("hides create button when user lacks templates:manage", () => { + setupMocks({ canManage: false }); + render( + + + , + ); + expect(screen.queryByTestId("create-template-button")).toBeNull(); + }); + + it("renders template cards from query", () => { + setupMocks(); + render( + + + , + ); + expect(screen.getAllByTestId("template-card")).toHaveLength(1); + }); + + it("shows empty state when no templates", () => { + setupMocks({ templates: [] }); + render( + + + , + ); + expect(screen.getByTestId("template-gallery-empty")).toBeDefined(); + }); + + it("shows loading state when query is loading", () => { + (rbacHook.useAcadenicePermissions as ReturnType).mockReturnValue({ + permissions: ["templates:manage"], + hasPermission: () => true, + canManageRoles: false, + isLoading: false, + }); + (queries.useTemplatesQuery as ReturnType).mockReturnValue({ + data: [], + isLoading: true, + }); + (queries.useCreateTemplateMutation as ReturnType).mockReturnValue(noopMutation); + (queries.useUpdateTemplateMutation as ReturnType).mockReturnValue(noopMutation); + (queries.useDeleteTemplateMutation as ReturnType).mockReturnValue(noopMutation); + (queries.useSetDefaultTemplateMutation as ReturnType).mockReturnValue(noopMutation); + (queries.useInstantiateTemplateMutation as ReturnType).mockReturnValue(noopMutation); + + render( + + + , + ); + expect(screen.getByTestId("template-gallery-loading")).toBeDefined(); + }); + + it("opens create form modal when create button clicked", async () => { + setupMocks({ canManage: true }); + render( + + + , + ); + fireEvent.click(screen.getByTestId("create-template-button")); + expect(await screen.findByTestId("template-form-modal")).toBeDefined(); + }); + + it("search input is rendered", () => { + setupMocks(); + render( + + + , + ); + expect(screen.getByTestId("template-search-input")).toBeDefined(); + }); + + it("category filter is rendered", () => { + setupMocks(); + render( + + + , + ); + expect(screen.getByTestId("template-category-filter")).toBeDefined(); + }); +}); diff --git a/apps/client/src/features/acadenice/templates-admin/components/template-card.tsx b/apps/client/src/features/acadenice/templates-admin/components/template-card.tsx new file mode 100644 index 00000000..aaa7e80f --- /dev/null +++ b/apps/client/src/features/acadenice/templates-admin/components/template-card.tsx @@ -0,0 +1,170 @@ +import React from "react"; +import { + Card, + Text, + Badge, + Group, + ActionIcon, + Menu, + Tooltip, + ThemeIcon, +} from "@mantine/core"; +import { + IconDots, + IconStar, + IconStarFilled, + IconTrash, + IconPencil, + IconCopy, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { TemplateDto } from "../services/templates-client"; + +interface TemplateCardProps { + template: TemplateDto; + onUse: (template: TemplateDto) => void; + onEdit?: (template: TemplateDto) => void; + onDelete?: (template: TemplateDto) => void; + onSetDefault?: (template: TemplateDto) => void; + canManage: boolean; +} + +const CATEGORY_COLORS: Record = { + meeting: "blue", + project: "violet", + wiki: "teal", + todo: "orange", + custom: "gray", +}; + +export default function TemplateCard({ + template, + onUse, + onEdit, + onDelete, + onSetDefault, + canManage, +}: TemplateCardProps) { + const { t } = useTranslation(); + + return ( + + + + {template.icon && ( + + )} + + {template.name} + + {template.isWorkspaceDefault && ( + + + + )} + {template.isBuiltIn && ( + + {t("templates.built_in_badge")} + + )} + + + + + e.stopPropagation()} + > + + + + + { + e.stopPropagation(); + onUse(template); + }} + leftSection={} + > + {t("templates.use_action")} + + + {canManage && !template.isBuiltIn && onEdit && ( + { + e.stopPropagation(); + onEdit(template); + }} + leftSection={} + > + {t("templates.edit_action")} + + )} + + {canManage && onSetDefault && !template.isWorkspaceDefault && ( + { + e.stopPropagation(); + onSetDefault(template); + }} + leftSection={} + > + {t("templates.set_default_action")} + + )} + + {canManage && !template.isBuiltIn && onDelete && ( + <> + + { + e.stopPropagation(); + onDelete(template); + }} + leftSection={} + > + {t("templates.delete_action")} + + + )} + + + + + {template.category && ( + + {t(`templates.category_${template.category}`)} + + )} + + {template.description && ( + + {template.description} + + )} + + + + {t("templates.usage_count", { count: template.usageCount })} + + + + ); +} diff --git a/apps/client/src/features/acadenice/templates-admin/components/template-form.tsx b/apps/client/src/features/acadenice/templates-admin/components/template-form.tsx new file mode 100644 index 00000000..6db868a3 --- /dev/null +++ b/apps/client/src/features/acadenice/templates-admin/components/template-form.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import { + Modal, + TextInput, + Textarea, + Select, + Button, + Group, + Stack, + Text, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { useTranslation } from "react-i18next"; +import { TemplateDto } from "../services/templates-client"; + +const CATEGORIES = [ + { value: "meeting", label: "Meeting" }, + { value: "project", label: "Project" }, + { value: "wiki", label: "Wiki" }, + { value: "todo", label: "Todo" }, + { value: "custom", label: "Custom" }, +]; + +interface TemplateFormProps { + opened: boolean; + onClose: () => void; + onSubmit: (values: { + name: string; + description?: string; + icon?: string; + category?: string; + }) => void; + initial?: Partial; + isSubmitting?: boolean; + title: string; +} + +export default function TemplateForm({ + opened, + onClose, + onSubmit, + initial, + isSubmitting, + title, +}: TemplateFormProps) { + const { t } = useTranslation(); + + const form = useForm({ + initialValues: { + name: initial?.name ?? "", + description: initial?.description ?? "", + icon: initial?.icon ?? "", + category: initial?.category ?? "custom", + }, + validate: { + name: (v) => + v.trim().length === 0 ? t("templates.name_required") : null, + }, + }); + + function handleSubmit(values: typeof form.values) { + onSubmit({ + name: values.name.trim(), + description: values.description?.trim() || undefined, + icon: values.icon?.trim() || undefined, + category: values.category || undefined, + }); + } + + return ( + +
+ + + + + +