From be951a22ac75db4bf3011f097a8a5345b140c9cb Mon Sep 17 00:00:00 2001 From: Corentin Date: Fri, 8 May 2026 02:47:15 +0200 Subject: [PATCH] feat(acadenice): add inline comments threads for R3.8 (30 tests, Patch 016) Page comments: REST resolve/unresolve facade over native comments table (PageCommentResolveService + CollaborationGateway yjs mark sync). Row comments: new acadenice_row_comment table + full CRUD + resolve (RowCommentService, RowCommentsController) + React panel in RowDetailModal. 4 new permissions: comments:read/write/resolve/moderate (30 total). i18n FR+EN. R3 ENTIEREMENT TERMINE. Co-Authored-By: Claude Sonnet 4.6 --- ACADENICE_PATCHES.md | 95 ++++- .../public/locales/en-US/translation.json | 20 +- .../public/locales/fr-FR/translation.json | 20 +- .../__tests__/row-comments-client.test.ts | 137 ++++++++ .../__tests__/row-comments-panel.test.tsx | 115 ++++++ .../components/row-comments-panel.tsx | 328 ++++++++++++++++++ .../comments/hooks/use-row-comments.ts | 153 ++++++++ .../comments/services/row-comments-client.ts | 88 +++++ .../components/row-detail-modal.tsx | 83 +++-- .../acadenice/comments/comments.module.ts | 29 ++ .../controllers/page-comments.controller.ts | 49 +++ .../controllers/row-comments.controller.ts | 132 +++++++ .../acadenice/comments/dto/comment.dto.ts | 81 +++++ .../services/page-comment-resolve.service.ts | 91 +++++ .../comments/services/row-comment.service.ts | 279 +++++++++++++++ .../spec/page-comment-resolve.service.spec.ts | 127 +++++++ .../comments/spec/row-comment.service.spec.ts | 171 +++++++++ .../spec/row-comments.controller.spec.ts | 120 +++++++ .../acadenice/rbac/permissions-catalog.ts | 26 ++ apps/server/src/core/core.module.ts | 3 + ...260508T180000-create-acadenice-comments.ts | 47 +++ 21 files changed, 2161 insertions(+), 33 deletions(-) create mode 100644 apps/client/src/features/acadenice/comments/__tests__/row-comments-client.test.ts create mode 100644 apps/client/src/features/acadenice/comments/__tests__/row-comments-panel.test.tsx create mode 100644 apps/client/src/features/acadenice/comments/components/row-comments-panel.tsx create mode 100644 apps/client/src/features/acadenice/comments/hooks/use-row-comments.ts create mode 100644 apps/client/src/features/acadenice/comments/services/row-comments-client.ts create mode 100644 apps/server/src/core/acadenice/comments/comments.module.ts create mode 100644 apps/server/src/core/acadenice/comments/controllers/page-comments.controller.ts create mode 100644 apps/server/src/core/acadenice/comments/controllers/row-comments.controller.ts create mode 100644 apps/server/src/core/acadenice/comments/dto/comment.dto.ts create mode 100644 apps/server/src/core/acadenice/comments/services/page-comment-resolve.service.ts create mode 100644 apps/server/src/core/acadenice/comments/services/row-comment.service.ts create mode 100644 apps/server/src/core/acadenice/comments/spec/page-comment-resolve.service.spec.ts create mode 100644 apps/server/src/core/acadenice/comments/spec/row-comment.service.spec.ts create mode 100644 apps/server/src/core/acadenice/comments/spec/row-comments.controller.spec.ts create mode 100644 apps/server/src/database/migrations/20260508T180000-create-acadenice-comments.ts diff --git a/ACADENICE_PATCHES.md b/ACADENICE_PATCHES.md index f9450630..c65f5e8c 100644 --- a/ACADENICE_PATCHES.md +++ b/ACADENICE_PATCHES.md @@ -101,7 +101,100 @@ PUT /api/acadenice/notification-preferences update prefs ### Prochaine etape -R3.8 — comments inline (threads sur paragraphes Tiptap + sur rows database, resolu/non-resolu, notif R3.7 sur reply via hook dedie). +R3.8 LIVRE (Patch 016). R3 ENTIEREMENT TERMINE. + +--- + +## Patch 016 — R3.8 inline comments threads (page resolve + row comments) + +**Date** : 2026-05-08 +**Scope** : REST resolve facade for page comments, new acadenice_row_comment table, row comment threads UI in row-detail-modal, i18n FR+EN, 4 new permissions (30 total) + +### Architecture Decision + +**Page comments** : Docmost native `comments` table is already complete (inline +selection via yjsSelection, resolve tracking via `resolved_at`/`resolved_by_id`, +threaded replies, WS events). The full client-side comment panel, comment mark +Tiptap extension, and create/list/edit/delete/resolve flows are already shipped +natively (CommentController, CommentService, comment-list-with-tabs, etc.). + +R3.8 adds the ONLY missing piece: a **REST resolve endpoint**. The native resolve +is collab-only (hocuspocus websocket). The frontend `resolveComment()` already +calls `/comments/resolve` but that route is absent from the native OSS controller. +`PageCommentResolveService` adds the endpoint and synchronizes the yjs mark +(best-effort via CollaborationGateway.handleYjsEvent). + +**Row comments** : New `acadenice_row_comment` table. Row identity = (table_id, +row_id) — external string pair, no FK to Baserow. Thread model mirrors native +page comments (flat, root + replies only, root-only resolve). Comments panel +added as a new tab in `RowDetailModal`. + +**Permissions** : 4 new keys added (comments:read/write/resolve/moderate). +Catalogue now at 30 named permissions + admin:*. + +### Nouveaux fichiers backend + +| Fichier | Role | +|---------|------| +| `apps/server/src/database/migrations/20260508T180000-create-acadenice-comments.ts` | Migration acadenice_row_comment + indexes | +| `apps/server/src/core/acadenice/comments/comments.module.ts` | NestJS module | +| `apps/server/src/core/acadenice/comments/dto/comment.dto.ts` | DTOs (row + page resolve) | +| `apps/server/src/core/acadenice/comments/services/row-comment.service.ts` | CRUD + resolve row comments | +| `apps/server/src/core/acadenice/comments/services/page-comment-resolve.service.ts` | REST resolve facade for native comments | +| `apps/server/src/core/acadenice/comments/controllers/row-comments.controller.ts` | REST /api/acadenice/row-comments/* | +| `apps/server/src/core/acadenice/comments/controllers/page-comments.controller.ts` | REST /api/acadenice/page-comments/resolve | +| `apps/server/src/core/acadenice/comments/spec/row-comment.service.spec.ts` | 8 unit tests | +| `apps/server/src/core/acadenice/comments/spec/page-comment-resolve.service.spec.ts` | 5 unit tests | +| `apps/server/src/core/acadenice/comments/spec/row-comments.controller.spec.ts` | 6 unit tests | + +### Nouveaux fichiers frontend + +| Fichier | Role | +|---------|------| +| `apps/client/src/features/acadenice/comments/services/row-comments-client.ts` | Axios HTTP client (6 functions) | +| `apps/client/src/features/acadenice/comments/hooks/use-row-comments.ts` | React Query hooks (list, count, create, resolve, delete) | +| `apps/client/src/features/acadenice/comments/components/row-comments-panel.tsx` | Row comment thread panel + composer | +| `apps/client/src/features/acadenice/comments/__tests__/row-comments-client.test.ts` | 7 client tests | +| `apps/client/src/features/acadenice/comments/__tests__/row-comments-panel.test.tsx` | 4 render tests | + +### Fichiers upstream modifies (patches) + +| Fichier | Modification | +|---------|-------------| +| `apps/server/src/core/core.module.ts` | +AcadeniceCommentsModule import + registration | +| `apps/server/src/core/acadenice/rbac/permissions-catalog.ts` | +4 comment permissions (comments:read/write/resolve/moderate) — 30 total | +| `apps/client/src/features/acadenice/database-view/components/row-detail-modal.tsx` | +Comments tab (Tabs) + RowCommentsPanel | +| `apps/client/public/locales/en-US/translation.json` | +17 acadenice.comments.* + 2 database_view.row_detail.tab_* keys | +| `apps/client/public/locales/fr-FR/translation.json` | +17 cles FR | + +### Endpoints + +``` +POST /api/acadenice/page-comments/resolve resolve/unresolve native page comment thread + +POST /api/acadenice/row-comments/list list row comment threads +POST /api/acadenice/row-comments/create create root or reply +POST /api/acadenice/row-comments/update edit own comment +POST /api/acadenice/row-comments/resolve resolve/unresolve root thread +POST /api/acadenice/row-comments/delete delete own (+ moderator) +POST /api/acadenice/row-comments/count count comments for badge +``` + +### Tests + +- Backend : 8 row-comment.service + 5 page-comment-resolve.service + 6 row-comments.controller = 19 tests +- Frontend : 7 client + 4 panel render = 11 tests +- Total R3.8 : 30 tests + +### Points a debattre avec Corentin + +1. **Page comment resolve REST vs collab-only** : La native resolve passe par hocuspocus (WS). Le nouvel endpoint REST met a jour la DB + tente de syncer le yjs mark via CollaborationGateway. Si le document n'est pas ouvert dans hocuspocus, le mark ne change pas visuellement jusqu'au prochain reload. C'est acceptable pour v1 — une alternative serait de stocker le resolved state uniquement en DB et ne pas dependre du yjs mark. +2. **Row comment content** : stocke en Tiptap JSON mais affiche en texte brut dans le panel. Pour la richesse (bold, mentions dans les row comments), il faudrait un mini TipTap read-only renderer. Slote pour R4+. +3. **Permissions row comments** : actuellement pas de check explicite `comments:write` dans le controller — tout user authentifie avec acces workspace peut commenter. Ajouter `RequirePermission('comments:write')` si un RBAC plus fin est souhaite. + +### Prochaine etape + +R3 ENTIEREMENT TERMINE. Recommandation : audit (pnpm install + pnpm typecheck + pnpm test) + install deps manquants si necessaire + e2e local. --- diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index effa28f8..ba60d067 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1186,5 +1186,21 @@ "acadenice.notifications.prefs.in_app_mentions": "In-app — page mentions", "acadenice.notifications.prefs.in_app_mentions_desc": "Receive an in-app notification when someone mentions you on a page.", "acadenice.notifications.prefs.in_app_replies": "In-app — comment replies", - "acadenice.notifications.prefs.in_app_replies_desc": "Receive an in-app notification when someone replies to your comment." -} \ No newline at end of file + "acadenice.notifications.prefs.in_app_replies_desc": "Receive an in-app notification when someone replies to your comment.", + "database_view.row_detail.tab_fields": "Fields", + "database_view.row_detail.tab_comments": "Comments", + "acadenice.comments.open": "Open", + "acadenice.comments.resolved": "Resolved", + "acadenice.comments.empty": "No comments yet.", + "acadenice.comments.new_placeholder": "Write a comment...", + "acadenice.comments.reply_placeholder": "Write a reply...", + "acadenice.comments.send": "Comment", + "acadenice.comments.send_reply": "Reply", + "acadenice.comments.reply": "Reply", + "acadenice.comments.cancel": "Cancel", + "acadenice.comments.resolve_action": "Resolve thread", + "acadenice.comments.reopen_action": "Re-open thread", + "acadenice.comments.delete_action": "Delete comment", + "acadenice.comments.resolved_badge": "Resolved", + "acadenice.comments.unknown_user": "Unknown user" +} diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 42dfae0f..06cf87f4 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -1141,5 +1141,21 @@ "acadenice.notifications.prefs.in_app_mentions": "In-app — mentions sur une page", "acadenice.notifications.prefs.in_app_mentions_desc": "Recevoir une notification quand quelqu'un vous mentionne sur une page.", "acadenice.notifications.prefs.in_app_replies": "In-app — reponses a vos commentaires", - "acadenice.notifications.prefs.in_app_replies_desc": "Recevoir une notification quand quelqu'un repond a votre commentaire." -} \ No newline at end of file + "acadenice.notifications.prefs.in_app_replies_desc": "Recevoir une notification quand quelqu'un repond a votre commentaire.", + "database_view.row_detail.tab_fields": "Champs", + "database_view.row_detail.tab_comments": "Commentaires", + "acadenice.comments.open": "Ouverts", + "acadenice.comments.resolved": "Resolus", + "acadenice.comments.empty": "Aucun commentaire.", + "acadenice.comments.new_placeholder": "Ecrire un commentaire...", + "acadenice.comments.reply_placeholder": "Ecrire une reponse...", + "acadenice.comments.send": "Commenter", + "acadenice.comments.send_reply": "Repondre", + "acadenice.comments.reply": "Repondre", + "acadenice.comments.cancel": "Annuler", + "acadenice.comments.resolve_action": "Resoudre le fil", + "acadenice.comments.reopen_action": "Rouvrir le fil", + "acadenice.comments.delete_action": "Supprimer", + "acadenice.comments.resolved_badge": "Resolu", + "acadenice.comments.unknown_user": "Utilisateur inconnu" +} diff --git a/apps/client/src/features/acadenice/comments/__tests__/row-comments-client.test.ts b/apps/client/src/features/acadenice/comments/__tests__/row-comments-client.test.ts new file mode 100644 index 00000000..79142a0c --- /dev/null +++ b/apps/client/src/features/acadenice/comments/__tests__/row-comments-client.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * Unit tests for row-comments-client (R3.8). + * + * api-client is fully mocked — no network calls. + */ + +vi.mock("@/lib/api-client", () => ({ + default: { + post: vi.fn(), + }, +})); + +import api from "@/lib/api-client"; +import { + listRowComments, + createRowComment, + updateRowComment, + resolveRowComment, + deleteRowComment, + countRowComments, +} from "../services/row-comments-client"; + +const mockApi = api as { post: ReturnType }; + +const TABLE_ID = "table-1"; +const ROW_ID = "row-42"; +const COMMENT_ID = "c-00000000"; + +function makeComment() { + return { + id: COMMENT_ID, + tableId: TABLE_ID, + rowId: ROW_ID, + content: { type: "doc", content: [] }, + authorUserId: "user-1", + isResolved: false, + createdAt: "2026-05-08T12:00:00Z", + updatedAt: "2026-05-08T12:00:00Z", + parentCommentId: null, + workspaceId: "ws-1", + resolvedAt: null, + resolvedBy: null, + }; +} + +describe("row-comments-client", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("listRowComments posts to /acadenice/row-comments/list", async () => { + const comment = makeComment(); + mockApi.post.mockResolvedValueOnce({ data: [comment] }); + + const result = await listRowComments({ tableId: TABLE_ID, rowId: ROW_ID }); + + expect(mockApi.post).toHaveBeenCalledWith( + "/acadenice/row-comments/list", + { tableId: TABLE_ID, rowId: ROW_ID }, + ); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(COMMENT_ID); + }); + + it("listRowComments forwards resolved filter", async () => { + mockApi.post.mockResolvedValueOnce({ data: [] }); + await listRowComments({ tableId: TABLE_ID, rowId: ROW_ID, resolved: true }); + expect(mockApi.post).toHaveBeenCalledWith( + "/acadenice/row-comments/list", + { tableId: TABLE_ID, rowId: ROW_ID, resolved: true }, + ); + }); + + it("createRowComment posts to /acadenice/row-comments/create", async () => { + const comment = makeComment(); + mockApi.post.mockResolvedValueOnce({ data: comment }); + + const params = { + tableId: TABLE_ID, + rowId: ROW_ID, + content: JSON.stringify({ type: "doc" }), + }; + const result = await createRowComment(params); + + expect(mockApi.post).toHaveBeenCalledWith( + "/acadenice/row-comments/create", + params, + ); + expect(result.id).toBe(COMMENT_ID); + }); + + it("updateRowComment posts to /acadenice/row-comments/update", async () => { + const comment = makeComment(); + mockApi.post.mockResolvedValueOnce({ data: comment }); + + await updateRowComment(COMMENT_ID, JSON.stringify({ type: "doc" })); + + expect(mockApi.post).toHaveBeenCalledWith( + "/acadenice/row-comments/update", + { commentId: COMMENT_ID, content: JSON.stringify({ type: "doc" }) }, + ); + }); + + it("resolveRowComment posts to /acadenice/row-comments/resolve", async () => { + const comment = { ...makeComment(), isResolved: true }; + mockApi.post.mockResolvedValueOnce({ data: comment }); + + const result = await resolveRowComment(COMMENT_ID, true); + + expect(mockApi.post).toHaveBeenCalledWith( + "/acadenice/row-comments/resolve", + { commentId: COMMENT_ID, resolved: true }, + ); + expect(result.isResolved).toBe(true); + }); + + it("deleteRowComment posts to /acadenice/row-comments/delete", async () => { + mockApi.post.mockResolvedValueOnce({ data: undefined }); + await deleteRowComment(COMMENT_ID); + expect(mockApi.post).toHaveBeenCalledWith( + "/acadenice/row-comments/delete", + { commentId: COMMENT_ID }, + ); + }); + + it("countRowComments returns numeric count", async () => { + mockApi.post.mockResolvedValueOnce({ data: { count: 7 } }); + const count = await countRowComments(TABLE_ID, ROW_ID); + expect(count).toBe(7); + expect(mockApi.post).toHaveBeenCalledWith( + "/acadenice/row-comments/count", + { tableId: TABLE_ID, rowId: ROW_ID }, + ); + }); +}); diff --git a/apps/client/src/features/acadenice/comments/__tests__/row-comments-panel.test.tsx b/apps/client/src/features/acadenice/comments/__tests__/row-comments-panel.test.tsx new file mode 100644 index 00000000..8af6377d --- /dev/null +++ b/apps/client/src/features/acadenice/comments/__tests__/row-comments-panel.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { RowCommentsPanel } from "../components/row-comments-panel"; + +/** + * Render tests for RowCommentsPanel (R3.8). + * + * All hooks and translations are mocked — no network, no Jotai store. + */ + +vi.mock("../hooks/use-row-comments", () => ({ + useRowComments: vi.fn(), + useCreateRowComment: vi.fn(), + useResolveRowComment: vi.fn(), + useDeleteRowComment: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +import { + useRowComments, + useCreateRowComment, + useResolveRowComment, + useDeleteRowComment, +} from "../hooks/use-row-comments"; + +function makeMutationResult() { + return { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }; +} + +const TABLE_ID = "table-1"; +const ROW_ID = "row-1"; +const USER_ID = "user-abc"; + +describe("RowCommentsPanel", () => { + beforeEach(() => { + vi.mocked(useCreateRowComment).mockReturnValue(makeMutationResult() as any); + vi.mocked(useResolveRowComment).mockReturnValue(makeMutationResult() as any); + vi.mocked(useDeleteRowComment).mockReturnValue(makeMutationResult() as any); + }); + + it("shows empty state when no comments", () => { + vi.mocked(useRowComments).mockReturnValue({ + data: [], + isLoading: false, + } as any); + + render( + , + ); + + expect(screen.getByText("acadenice.comments.empty")).toBeDefined(); + }); + + it("shows loading state", () => { + vi.mocked(useRowComments).mockReturnValue({ + data: [], + isLoading: true, + } as any); + + render( + , + ); + + // Mantine Loader renders a loading indicator; just verify panel mounts + expect(screen.queryByText("acadenice.comments.empty")).toBeNull(); + }); + + it("renders open/resolved tab buttons", () => { + vi.mocked(useRowComments).mockReturnValue({ + data: [], + isLoading: false, + } as any); + + render( + , + ); + + expect(screen.getByText("acadenice.comments.open")).toBeDefined(); + expect(screen.getByText("acadenice.comments.resolved")).toBeDefined(); + }); + + it("renders compose textarea when panel is open", () => { + vi.mocked(useRowComments).mockReturnValue({ + data: [], + isLoading: false, + } as any); + + render( + , + ); + + expect(screen.getByPlaceholderText("acadenice.comments.new_placeholder")).toBeDefined(); + }); +}); diff --git a/apps/client/src/features/acadenice/comments/components/row-comments-panel.tsx b/apps/client/src/features/acadenice/comments/components/row-comments-panel.tsx new file mode 100644 index 00000000..a7ec249a --- /dev/null +++ b/apps/client/src/features/acadenice/comments/components/row-comments-panel.tsx @@ -0,0 +1,328 @@ +import { + Stack, + Text, + Group, + Button, + Textarea, + Badge, + ActionIcon, + Loader, + Paper, + Divider, + Tooltip, +} from "@mantine/core"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + useRowComments, + useCreateRowComment, + useResolveRowComment, + useDeleteRowComment, +} from "../hooks/use-row-comments"; +import type { RowComment } from "../services/row-comments-client"; + +interface RowCommentsPanelProps { + tableId: string; + rowId: string; + /** Current user id — used to show own-comment controls */ + currentUserId: string; +} + +/** + * RowCommentsPanel — threaded comment panel for a Baserow row (R3.8). + * + * Shows unresolved threads by default; a "Resolved" tab toggles to archived. + * Compose area creates root comments; "Reply" button creates a child. + * + * Architecture note: content is stored as Tiptap JSON on the server but + * displayed as plain text here. A future enhancement could plug in a + * read-only Tiptap renderer — out of scope for R3.8 (Notion-like phase 1). + */ +export function RowCommentsPanel({ + tableId, + rowId, + currentUserId, +}: RowCommentsPanelProps) { + const { t } = useTranslation(); + const [showResolved, setShowResolved] = useState(false); + const [draft, setDraft] = useState(""); + const [replyTo, setReplyTo] = useState(null); + + const { data: comments = [], isLoading } = useRowComments( + tableId, + rowId, + showResolved ? true : false, + ); + + const createMutation = useCreateRowComment(); + const resolveMutation = useResolveRowComment(); + const deleteMutation = useDeleteRowComment(); + + function tiptapDocFromText(text: string) { + return JSON.stringify({ + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text }] }], + }); + } + + function extractText(content: Record | string): string { + if (!content) return ""; + const doc = + typeof content === "string" ? JSON.parse(content) : content; + if (!doc?.content) return ""; + const texts: string[] = []; + function walk(node: Record) { + if (node.type === "text" && typeof node.text === "string") { + texts.push(node.text); + } + if (Array.isArray(node.content)) { + (node.content as Record[]).forEach(walk); + } + } + (doc.content as Record[]).forEach(walk); + return texts.join(" "); + } + + async function handleSubmit() { + const text = draft.trim(); + if (!text) return; + + await createMutation.mutateAsync({ + tableId, + rowId, + content: tiptapDocFromText(text), + parentCommentId: replyTo ?? undefined, + }); + + setDraft(""); + setReplyTo(null); + } + + const rootComments = comments.filter((c) => c.parentCommentId === null); + + return ( + + {/* Tab toggle */} + + + + + + {isLoading && } + + {!isLoading && rootComments.length === 0 && ( + + {t("acadenice.comments.empty")} + + )} + + {/* Thread list */} + {rootComments.map((root) => { + const replies = comments.filter((c) => c.parentCommentId === root.id); + return ( + + setReplyTo(root.id)} + onResolve={(resolved) => + resolveMutation.mutate({ + commentId: root.id, + resolved, + tableId, + rowId, + }) + } + onDelete={() => + deleteMutation.mutate({ commentId: root.id, tableId, rowId }) + } + /> + + {replies.map((reply) => ( + + + deleteMutation.mutate({ + commentId: reply.id, + tableId, + rowId, + }) + } + /> + + ))} + + {replyTo === root.id && ( + +