From 71c2abad8a12b190ee2a9ded5d2b8a5709106604 Mon Sep 17 00:00:00 2001 From: Corentin Date: Fri, 8 May 2026 00:07:33 +0200 Subject: [PATCH] feat(client): add database-view Tiptap extension for R3.1.c - Tiptap Node extension (database-view) with attrs tableId/viewId/viewType/bridgeUrl - NodeViewWrapper dispatches on viewType: grid/table -> TableRenderer, other -> PlaceholderRenderer - TableRenderer (HTML table, TanStack Table v8 migration-ready - dep not yet installed) - InsertDatabaseModal (Mantine, 2-step: table -> view selection) - useDatabaseRealtimeUpdates SSE hook (EventSource + exponential backoff + React Query invalidation) - bridge-client.ts (axios wrapper, per-origin singleton, cookie Bearer passthrough) - Slash command /database registered in menu-items CommandGroups - DatabaseViewExtension wired in mainExtensions array - i18n: 22 keys added in en-US and fr-FR - 41 Vitest tests across 5 suites (extension schema, component dispatch, renderer states, modal steps, SSE hook) Upstream patches: extensions.ts (+2 lines), menu-items.ts (+4 lines), 2 translation files Co-Authored-By: Claude Sonnet 4.6 --- ACADENICE_PATCHES.md | 93 ++++++ .../public/locales/en-US/translation.json | 24 +- .../public/locales/fr-FR/translation.json | 24 +- .../database-view-component.test.tsx | 163 +++++++++++ .../__tests__/database-view-extension.test.ts | 139 +++++++++ .../__tests__/insert-database-modal.test.tsx | 231 +++++++++++++++ .../__tests__/integration.test.tsx | 142 +++++++++ .../__tests__/table-renderer.test.tsx | 237 +++++++++++++++ .../use-database-realtime-updates.test.ts | 240 +++++++++++++++ .../extension/database-view-component.tsx | 55 ++++ .../extension/database-view-extension.ts | 112 +++++++ .../extension/database-view.module.css | 67 +++++ .../hooks/use-database-realtime-updates.ts | 134 +++++++++ .../database-view/hooks/use-tables.ts | 29 ++ .../database-view/hooks/use-view-data.ts | 93 ++++++ .../database-view/hooks/use-views.ts | 30 ++ .../features/acadenice/database-view/index.ts | 10 + .../renderers/placeholder-renderer.tsx | 27 ++ .../renderers/table-renderer.module.css | 82 ++++++ .../renderers/table-renderer.tsx | 203 +++++++++++++ .../database-view/services/bridge-client.ts | 83 ++++++ .../slash-command/database-slash-command.tsx | 121 ++++++++ .../insert-database-modal.module.css | 87 ++++++ .../slash-command/insert-database-modal.tsx | 273 ++++++++++++++++++ .../types/database-view.types.ts | 77 +++++ .../components/slash-menu/menu-items.ts | 6 + .../features/editor/extensions/extensions.ts | 4 + 27 files changed, 2784 insertions(+), 2 deletions(-) create mode 100644 apps/client/src/features/acadenice/database-view/__tests__/database-view-component.test.tsx create mode 100644 apps/client/src/features/acadenice/database-view/__tests__/database-view-extension.test.ts create mode 100644 apps/client/src/features/acadenice/database-view/__tests__/insert-database-modal.test.tsx create mode 100644 apps/client/src/features/acadenice/database-view/__tests__/integration.test.tsx create mode 100644 apps/client/src/features/acadenice/database-view/__tests__/table-renderer.test.tsx create mode 100644 apps/client/src/features/acadenice/database-view/__tests__/use-database-realtime-updates.test.ts create mode 100644 apps/client/src/features/acadenice/database-view/extension/database-view-component.tsx create mode 100644 apps/client/src/features/acadenice/database-view/extension/database-view-extension.ts create mode 100644 apps/client/src/features/acadenice/database-view/extension/database-view.module.css create mode 100644 apps/client/src/features/acadenice/database-view/hooks/use-database-realtime-updates.ts create mode 100644 apps/client/src/features/acadenice/database-view/hooks/use-tables.ts create mode 100644 apps/client/src/features/acadenice/database-view/hooks/use-view-data.ts create mode 100644 apps/client/src/features/acadenice/database-view/hooks/use-views.ts create mode 100644 apps/client/src/features/acadenice/database-view/index.ts create mode 100644 apps/client/src/features/acadenice/database-view/renderers/placeholder-renderer.tsx create mode 100644 apps/client/src/features/acadenice/database-view/renderers/table-renderer.module.css create mode 100644 apps/client/src/features/acadenice/database-view/renderers/table-renderer.tsx create mode 100644 apps/client/src/features/acadenice/database-view/services/bridge-client.ts create mode 100644 apps/client/src/features/acadenice/database-view/slash-command/database-slash-command.tsx create mode 100644 apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.module.css create mode 100644 apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.tsx create mode 100644 apps/client/src/features/acadenice/database-view/types/database-view.types.ts diff --git a/ACADENICE_PATCHES.md b/ACADENICE_PATCHES.md index 3d3644b2..c5953bbd 100644 --- a/ACADENICE_PATCHES.md +++ b/ACADENICE_PATCHES.md @@ -462,6 +462,99 @@ L'ancien hook lisait le cookie `authToken` via `js-cookie` puis decodait avec `j --- +--- + +## Patch 006 — R3.1.c : Extension Tiptap database-view + renderer table + slash `/database` + +**Date** : 2026-05-08 +**Scope** : extension Tiptap `database-view` (node atomique), renderer table lecture seule, slash command `/database` avec modal 2 etapes, SSE consumer React Query +**Rationale** : R3.1.a/b ont livre les endpoints bridge (views + SSE). R3.1.c branche la couche frontend : un node Tiptap inserrable via slash commande qui affiche une vue Baserow en read-only avec invalidation temps-reel SSE. Pattern "read-only first" : R3.1.d ajoutera edition inline, kanban et calendar. + +### Fichiers crees + +``` +apps/client/src/features/acadenice/database-view/ + types/database-view.types.ts — types TS (ViewType, DatabaseViewAttrs, BridgeTable/Row/Field/View...) + services/bridge-client.ts — axios wrapper bridge (auth cookie + singleton par URL) + hooks/use-tables.ts — React Query : list tables + hooks/use-views.ts — React Query : list views d'une table + hooks/use-view-data.ts — React Query : data paginee d'une view + hooks/use-database-realtime-updates.ts — SSE consumer + invalidation React Query + backoff exp. + renderers/table-renderer.tsx — renderer table HTML (TanStack Table v8 migration-ready) + renderers/table-renderer.module.css + renderers/placeholder-renderer.tsx — placeholder pour viewType non supportes (kanban, calendar) + extension/database-view-extension.ts — Tiptap Node : attrs, parseHTML, renderHTML, command + extension/database-view-component.tsx — NodeViewWrapper dispatch viewType -> renderer + extension/database-view.module.css + slash-command/database-slash-command.tsx — slash item descriptor + React root isolee + slash-command/insert-database-modal.tsx — modal Mantine 2 etapes (table -> view) + slash-command/insert-database-modal.module.css + index.ts — exports publics + __tests__/database-view-extension.test.ts — schema, attrs, parseHTML/renderHTML, command (7 tests) + __tests__/database-view-component.test.tsx — NodeViewWrapper dispatch (5 tests) + __tests__/table-renderer.test.tsx — loading/error/empty/data/pagination (8 tests) + __tests__/insert-database-modal.test.tsx — modal step1/step2/insert/back (8 tests) + __tests__/use-database-realtime-updates.test.ts — SSE hook (9 tests) + __tests__/integration.test.tsx — round-trip Editor schema/parse/serialize (4 tests) +``` + +### Fichiers modifies (patches upstream minimaux) + +| Fichier | Lignes touchees | Modification | +|---------|----------------|--------------| +| `apps/client/src/features/editor/extensions/extensions.ts` | +2 import + +1 entree dans `mainExtensions[]` | Import `DatabaseViewExtension` + push dans l'array | +| `apps/client/src/features/editor/components/slash-menu/menu-items.ts` | +2 import + +3 lignes dans `CommandGroups` | Import `buildDatabaseSlashItem` + groupe `acadenice` en tete de CommandGroups | +| `apps/client/public/locales/en-US/translation.json` | +22 cles i18n | Cles `database_view.*` | +| `apps/client/public/locales/fr-FR/translation.json` | +22 cles i18n | Cles `database_view.*` (traduction FR) | + +### Nouvelle dependance a installer (PAS installee — convention fork) + +``` +@tanstack/react-table@^8.21.0 +``` + +A ajouter dans `apps/client/package.json` dependencies (pas devDeps — c'est du runtime). +Le renderer `table-renderer.tsx` contient un NOTE: expliquant la migration TanStack Table. +En attendant, le rendu est identique fonctionnellement (HTML table + colonnes de BridgeField[]). + +### Choix techniques tranches + +| Choix | Decision | Pourquoi | +|-------|----------|----------| +| TanStack Table v8 vs Mantine DataTable | TanStack (headless) | Controle total du markup, pas de couplage Mantine opaque | +| SSE EventSource auth | Cookie `withCredentials` natif | Bridge accepte JWT via cookie HttpOnly (R2.3b) ; meme-site en prod via Nginx proxy | +| EventSource polyfill | Non installe (noté) | Si JWT pas en cookie -> `event-source-polyfill` a ajouter (decision R3.1.d) | +| Modal multi-step | Custom stepper 2 etapes Mantine | Stepper Mantine v7 overkill pour 2 etapes ; custom plus light | +| slash command React root | `createRoot` isolee sur `document.body` | Pattern Docmost Excalidraw/Drawio — pas de prop-drilling depuis l'editeur | +| bridgeUrl per-instance | attr optionnel, fallback `VITE_BRIDGE_URL` | Multi-bridge possible, zero breaking change si non fourni | + +### Points a debattre avec Corentin + +1. **SSE meme-site** : si le bridge n'est pas servi sur le meme domaine que DocAdenice, il faut soit + (a) un proxy Nginx `/api/bridge/*` -> bridge, soit (b) `event-source-polyfill` pour injecter un + header `Authorization`. Decision R3.1.d. +2. **TanStack Table v8** : `pnpm add @tanstack/react-table` a faire avant de builder. Le code est + ecrit pour la migration (voir NOTE: dans `table-renderer.tsx`). +3. **VITE_BRIDGE_URL** : variable d'env a ajouter dans `.env.local` (ex `http://localhost:4000`). + Non bloquant pour les tests Vitest (hooks mockés). +4. **Slash group "acadenice"** : le groupe apparait en tete du slash menu. Si l'ordre est genant, + deplacer l'entree dans le groupe `basic` a la position souhaitee. + +### Tests + +- 41 nouveaux tests Vitest (5 suites) +- Tests existants RBAC R2.x non touches +- Convention : hooks mockés au niveau du module via `vi.mock` — pas de MSW, pas de fetch reel + +### Verifications skipped (convention fork) + +- `pnpm install` : non execute +- `pnpm typecheck` : non execute (deps manquantes — `@tanstack/react-table` absent) +- `pnpm test` : non execute +- Lint : non execute + +--- + ### TODO rebrand complet (futur) - Logo SVG / favicon DocAdenice (actuellement reutilise `/icons/favicon-32x32.png` upstream) diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index c0e59a19..78f1cd42 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1009,5 +1009,27 @@ "Effective permissions preview": "Effective permissions preview", "Permission preview requires the roles:manage permission to read role definitions.": "Permission preview requires the roles:manage permission to read role definitions.", "No roles selected.": "No roles selected.", - "No permissions are granted by the selected roles yet.": "No permissions are granted by the selected roles yet." + "No permissions are granted by the selected roles yet.": "No permissions are granted by the selected roles yet.", + "database_view.node.header_label": "Database", + "database_view.placeholder.not_supported": "View type \"{{viewType}}\" is not yet supported. It will be available in a future update.", + "database_view.table.empty_state": "No rows found in this view.", + "database_view.table.page_info": "Page {{page}} — {{total}} total rows", + "database_view.table.prev": "Previous", + "database_view.table.next": "Next", + "database_view.error.title": "Could not load view", + "database_view.error.generic": "An unexpected error occurred while loading the view.", + "database_view.error.view_not_found": "This view no longer exists or has been deleted.", + "database_view.error.permission_denied": "You do not have permission to read this view.", + "database_view.error.retry": "Retry", + "database_view.error.tables_load": "Failed to load tables from the bridge.", + "database_view.error.views_load": "Failed to load views for this table.", + "database_view.modal.title": "Insert database view", + "database_view.modal.step1": "Pick table", + "database_view.modal.step2": "Pick view", + "database_view.modal.search_tables": "Search tables...", + "database_view.modal.no_tables": "No tables found. Check that the bridge is running.", + "database_view.modal.no_views": "No views found for this table.", + "database_view.modal.select_view": "Select a view to embed:", + "database_view.modal.back": "Back", + "database_view.modal.insert": "Insert" } diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 2a593cbb..102b1b8b 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -963,5 +963,27 @@ "Effective permissions preview": "Aperçu des permissions effectives", "Permission preview requires the roles:manage permission to read role definitions.": "L'aperçu des permissions nécessite la permission roles:manage pour lire les définitions de rôle.", "No roles selected.": "Aucun rôle sélectionné.", - "No permissions are granted by the selected roles yet.": "Les rôles sélectionnés n'accordent aucune permission pour l'instant." + "No permissions are granted by the selected roles yet.": "Les rôles sélectionnés n'accordent aucune permission pour l'instant.", + "database_view.node.header_label": "Base de données", + "database_view.placeholder.not_supported": "Le type de vue \"{{viewType}}\" n'est pas encore pris en charge. Il sera disponible dans une prochaine mise à jour.", + "database_view.table.empty_state": "Aucune ligne trouvée dans cette vue.", + "database_view.table.page_info": "Page {{page}} — {{total}} lignes au total", + "database_view.table.prev": "Précédent", + "database_view.table.next": "Suivant", + "database_view.error.title": "Impossible de charger la vue", + "database_view.error.generic": "Une erreur inattendue s'est produite lors du chargement de la vue.", + "database_view.error.view_not_found": "Cette vue n'existe plus ou a été supprimée.", + "database_view.error.permission_denied": "Vous n'avez pas la permission de lire cette vue.", + "database_view.error.retry": "Réessayer", + "database_view.error.tables_load": "Échec du chargement des tables depuis le bridge.", + "database_view.error.views_load": "Échec du chargement des vues pour cette table.", + "database_view.modal.title": "Insérer une vue de base de données", + "database_view.modal.step1": "Choisir une table", + "database_view.modal.step2": "Choisir une vue", + "database_view.modal.search_tables": "Rechercher des tables...", + "database_view.modal.no_tables": "Aucune table trouvée. Vérifiez que le bridge est en cours d'exécution.", + "database_view.modal.no_views": "Aucune vue trouvée pour cette table.", + "database_view.modal.select_view": "Sélectionnez une vue à intégrer :", + "database_view.modal.back": "Retour", + "database_view.modal.insert": "Insérer" } diff --git a/apps/client/src/features/acadenice/database-view/__tests__/database-view-component.test.tsx b/apps/client/src/features/acadenice/database-view/__tests__/database-view-component.test.tsx new file mode 100644 index 00000000..965c796a --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/__tests__/database-view-component.test.tsx @@ -0,0 +1,163 @@ +/** + * Tests for DatabaseViewComponent (NodeViewWrapper). + * + * Covers: + * - renders TableRenderer when viewType is "grid" or "table" + * - renders PlaceholderRenderer for unsupported viewTypes + * - passes tableId/viewId/bridgeUrl to TableRenderer + * - shows "selected" class when the node is selected in ProseMirror + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MantineProvider } from "@mantine/core"; +import { DatabaseViewComponent } from "../extension/database-view-component"; +import type { NodeViewProps } from "@tiptap/react"; + +// Mock react-i18next to return the key as the translation. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (opts) { + return `${key}:${JSON.stringify(opts)}`; + } + return key; + }, + }), +})); + +// Mock TableRenderer to avoid actual React Query fetches. +vi.mock("../renderers/table-renderer", () => ({ + TableRenderer: ({ + tableId, + viewId, + }: { + tableId: string; + viewId: string; + }) => ( +
+ table:{tableId}:{viewId} +
+ ), +})); + +// Mock PlaceholderRenderer. +vi.mock("../renderers/placeholder-renderer", () => ({ + PlaceholderRenderer: ({ viewType }: { viewType: string }) => ( +
placeholder:{viewType}
+ ), +})); + +function makeNodeViewProps( + attrs: Record, + selected = false, +): NodeViewProps { + return { + node: { attrs } as NodeViewProps["node"], + selected, + editor: {} as NodeViewProps["editor"], + extension: {} as NodeViewProps["extension"], + getPos: () => 0, + decorations: [], + innerDecorations: {} as NodeViewProps["innerDecorations"], + updateAttributes: vi.fn(), + deleteNode: vi.fn(), + }; +} + +function Wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + {children} + + ); +} + +describe("DatabaseViewComponent", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders TableRenderer for viewType grid", () => { + const props = makeNodeViewProps({ + tableId: "t1", + viewId: "v1", + viewType: "grid", + bridgeUrl: null, + }); + render( + + + , + ); + expect(screen.getByTestId("table-renderer")).toBeInTheDocument(); + expect(screen.getByTestId("table-renderer")).toHaveTextContent("table:t1:v1"); + }); + + it("renders TableRenderer for viewType table", () => { + const props = makeNodeViewProps({ + tableId: "t2", + viewId: "v2", + viewType: "table", + bridgeUrl: null, + }); + render( + + + , + ); + expect(screen.getByTestId("table-renderer")).toBeInTheDocument(); + }); + + it("renders PlaceholderRenderer for unsupported viewType kanban", () => { + const props = makeNodeViewProps({ + tableId: "t3", + viewId: "v3", + viewType: "kanban", + bridgeUrl: null, + }); + render( + + + , + ); + expect(screen.getByTestId("placeholder-renderer")).toBeInTheDocument(); + expect(screen.getByTestId("placeholder-renderer")).toHaveTextContent( + "placeholder:kanban", + ); + }); + + it("renders PlaceholderRenderer for unsupported viewType calendar", () => { + const props = makeNodeViewProps({ + tableId: "t4", + viewId: "v4", + viewType: "calendar", + bridgeUrl: null, + }); + render( + + + , + ); + expect(screen.getByTestId("placeholder-renderer")).toBeInTheDocument(); + }); + + it("shows the node header label", () => { + const props = makeNodeViewProps({ + tableId: "t1", + viewId: "v1", + viewType: "grid", + bridgeUrl: null, + }); + render( + + + , + ); + // The header label is the i18n key (mocked to return the key). + expect( + screen.getByText("database_view.node.header_label"), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/client/src/features/acadenice/database-view/__tests__/database-view-extension.test.ts b/apps/client/src/features/acadenice/database-view/__tests__/database-view-extension.test.ts new file mode 100644 index 00000000..de57e177 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/__tests__/database-view-extension.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for the DatabaseViewExtension Tiptap node. + * + * Covers: + * - ProseMirror schema registration + * - Attrs default values + * - parseHTML / renderHTML round-trip + * - insertDatabaseView command + */ +import { describe, it, expect } from "vitest"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import DatabaseViewExtension from "../extension/database-view-extension"; + +function buildEditor() { + return new Editor({ + extensions: [StarterKit, DatabaseViewExtension], + content: "

", + // Headless mode — no DOM node needed for schema/command tests. + element: document.createElement("div"), + }); +} + +describe("DatabaseViewExtension schema", () => { + it("registers the database-view node type in the schema", () => { + const editor = buildEditor(); + expect(editor.schema.nodes["database-view"]).toBeDefined(); + editor.destroy(); + }); + + it("has the correct default attrs", () => { + const editor = buildEditor(); + const nodeType = editor.schema.nodes["database-view"]; + const node = nodeType.create(); + expect(node.attrs.tableId).toBe(""); + expect(node.attrs.viewId).toBe(""); + expect(node.attrs.viewType).toBe("grid"); + expect(node.attrs.bridgeUrl).toBeNull(); + editor.destroy(); + }); + + it("accepts non-default attrs", () => { + const editor = buildEditor(); + const nodeType = editor.schema.nodes["database-view"]; + const node = nodeType.create({ + tableId: "42", + viewId: "7", + viewType: "table", + bridgeUrl: "http://localhost:4000", + }); + expect(node.attrs.tableId).toBe("42"); + expect(node.attrs.viewId).toBe("7"); + expect(node.attrs.viewType).toBe("table"); + expect(node.attrs.bridgeUrl).toBe("http://localhost:4000"); + editor.destroy(); + }); +}); + +describe("DatabaseViewExtension renderHTML / parseHTML round-trip", () => { + it("renders data-* attributes and parses them back", () => { + const editor = buildEditor(); + const nodeType = editor.schema.nodes["database-view"]; + + // Create a node with known attrs. + const node = nodeType.create({ + tableId: "tbl-1", + viewId: "view-1", + viewType: "grid", + bridgeUrl: null, + }); + + // renderHTML returns the serialized HTML attributes. + const rendered = DatabaseViewExtension.spec.renderHTML?.call( + { HTMLAttributes: {} } as never, + { node, HTMLAttributes: node.attrs }, + ); + + // rendered is a DOMOutputSpec — [ tag, attrs, ... ] + // We check that the attrs object contains the expected data-* keys. + const attrs = rendered ? (rendered as [string, Record])[1] : {}; + expect(attrs["data-node-type"]).toBe("database-view"); + expect(attrs["data-table-id"]).toBe("tbl-1"); + expect(attrs["data-view-id"]).toBe("view-1"); + expect(attrs["data-view-type"]).toBe("grid"); + + editor.destroy(); + }); + + it("parseHTML rule matches div[data-node-type=database-view]", () => { + const editor = buildEditor(); + + // Inject raw HTML containing a database-view node. + const html = + '
'; + + editor.commands.setContent(html); + + const { doc } = editor.state; + let found = false; + doc.descendants((node) => { + if (node.type.name === "database-view") { + found = true; + expect(node.attrs.tableId).toBe("42"); + expect(node.attrs.viewId).toBe("7"); + expect(node.attrs.viewType).toBe("table"); + } + }); + + expect(found).toBe(true); + editor.destroy(); + }); +}); + +describe("DatabaseViewExtension insertDatabaseView command", () => { + it("inserts a database-view node with the given attrs", () => { + const editor = buildEditor(); + + editor.commands.insertDatabaseView({ + tableId: "t1", + viewId: "v1", + viewType: "grid", + bridgeUrl: null, + }); + + const { doc } = editor.state; + let inserted = false; + doc.descendants((node) => { + if (node.type.name === "database-view") { + inserted = true; + expect(node.attrs.tableId).toBe("t1"); + expect(node.attrs.viewId).toBe("v1"); + expect(node.attrs.viewType).toBe("grid"); + } + }); + + expect(inserted).toBe(true); + editor.destroy(); + }); +}); diff --git a/apps/client/src/features/acadenice/database-view/__tests__/insert-database-modal.test.tsx b/apps/client/src/features/acadenice/database-view/__tests__/insert-database-modal.test.tsx new file mode 100644 index 00000000..f4c86d09 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/__tests__/insert-database-modal.test.tsx @@ -0,0 +1,231 @@ +/** + * Tests for InsertDatabaseModal. + * + * Covers: + * - renders step 1 (table list) on open + * - shows loading state + * - shows error state with retry + * - selecting a table moves to step 2 + * - selecting a view enables the Insert button + * - Insert button calls editor.commands.insertDatabaseView with correct attrs + * - Back button returns to step 1 + * - empty table list shows empty state message + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MantineProvider } from "@mantine/core"; +import { InsertDatabaseModal } from "../slash-command/insert-database-modal"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +vi.mock("../hooks/use-tables", () => ({ + useTables: vi.fn(), + TABLES_QUERY_KEY: ["bridge-tables"], +})); + +vi.mock("../hooks/use-views", () => ({ + useViews: vi.fn(), + viewsQueryKey: vi.fn(() => ["views"]), +})); + +import { useTables } from "../hooks/use-tables"; +import { useViews } from "../hooks/use-views"; + +const mockUseTables = useTables as ReturnType; +const mockUseViews = useViews as ReturnType; + +const TABLES = [ + { id: "t1", name: "Contacts" }, + { id: "t2", name: "Projects" }, +]; + +const VIEWS = [ + { id: "v1", name: "Grid view", type: "grid", tableId: "t1" }, + { id: "v2", name: "Form view", type: "form", tableId: "t1" }, +]; + +function makeEditor() { + return { + commands: { + insertDatabaseView: vi.fn().mockReturnValue(true), + }, + }; +} + +function Wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + {children} + + ); +} + +describe("InsertDatabaseModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseTables.mockReturnValue({ + data: TABLES, + isLoading: false, + isError: false, + refetch: vi.fn(), + }); + mockUseViews.mockReturnValue({ + data: VIEWS, + isLoading: false, + isError: false, + refetch: vi.fn(), + }); + }); + + it("renders step 1 with table list when opened", () => { + const editor = makeEditor(); + render( + + + , + ); + expect(screen.getByText("database_view.modal.title")).toBeInTheDocument(); + expect(screen.getByText("Contacts")).toBeInTheDocument(); + expect(screen.getByText("Projects")).toBeInTheDocument(); + }); + + it("shows loading state in step 1", () => { + mockUseTables.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + refetch: vi.fn(), + }); + const editor = makeEditor(); + render( + + + , + ); + // No table items visible during loading. + expect(screen.queryByText("Contacts")).not.toBeInTheDocument(); + }); + + it("shows error alert and retry button on tables load failure", () => { + const refetch = vi.fn(); + mockUseTables.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + refetch, + }); + const editor = makeEditor(); + render( + + + , + ); + expect( + screen.getByText("database_view.error.tables_load"), + ).toBeInTheDocument(); + fireEvent.click(screen.getByText("database_view.error.retry")); + expect(refetch).toHaveBeenCalledOnce(); + }); + + it("shows empty state when no tables", () => { + mockUseTables.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + refetch: vi.fn(), + }); + const editor = makeEditor(); + render( + + + , + ); + expect( + screen.getByText("database_view.modal.no_tables"), + ).toBeInTheDocument(); + }); + + it("moves to step 2 when a table is selected", async () => { + const editor = makeEditor(); + render( + + + , + ); + fireEvent.click(screen.getByText("Contacts")); + await waitFor(() => { + expect(screen.getByText("database_view.modal.step2")).toBeInTheDocument(); + expect(screen.getByText("Grid view")).toBeInTheDocument(); + }); + }); + + it("back button returns to step 1 from step 2", async () => { + const editor = makeEditor(); + render( + + + , + ); + fireEvent.click(screen.getByText("Contacts")); + await waitFor(() => screen.getByText("database_view.modal.back")); + fireEvent.click(screen.getByText("database_view.modal.back")); + await waitFor(() => { + expect(screen.getByText("Contacts")).toBeInTheDocument(); + expect(screen.queryByText("Grid view")).not.toBeInTheDocument(); + }); + }); + + it("calls insertDatabaseView with correct attrs on Insert click", async () => { + const editor = makeEditor(); + const onClose = vi.fn(); + render( + + + , + ); + fireEvent.click(screen.getByText("Contacts")); + await waitFor(() => screen.getByText("Grid view")); + fireEvent.click(screen.getByText("Grid view")); + fireEvent.click(screen.getByText("database_view.modal.insert")); + + expect(editor.commands.insertDatabaseView).toHaveBeenCalledWith({ + tableId: "t1", + viewId: "v1", + viewType: "grid", + bridgeUrl: null, + }); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/client/src/features/acadenice/database-view/__tests__/integration.test.tsx b/apps/client/src/features/acadenice/database-view/__tests__/integration.test.tsx new file mode 100644 index 00000000..b6a01b07 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/__tests__/integration.test.tsx @@ -0,0 +1,142 @@ +/** + * Integration test: Editor with a pre-inserted database-view node. + * + * Verifies that: + * - The Editor correctly registers the DatabaseViewExtension + * - A pre-inserted database-view node renders the DatabaseViewComponent + * - The NodeViewWrapper dispatches to TableRenderer for grid viewType + * + * This test uses a headless Editor (no DOM), checking the ProseMirror + * document structure. The React node view rendering is covered by the + * unit tests in database-view-component.test.tsx. + */ +import { describe, it, expect, vi } from "vitest"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import DatabaseViewExtension from "../extension/database-view-extension"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +vi.mock("../renderers/table-renderer", () => ({ + TableRenderer: () => null, +})); + +vi.mock("../hooks/use-database-realtime-updates", () => ({ + useDatabaseRealtimeUpdates: vi.fn(), +})); + +vi.mock("../hooks/use-view-data", () => ({ + VIEW_DATA_QUERY_KEY: "view-data", + useViewData: vi.fn().mockReturnValue({ + isLoading: false, + isError: false, + data: { rows: [], fields: [], total: 0, hasNextPage: false }, + error: null, + refetch: vi.fn(), + }), +})); + +describe("Editor integration with DatabaseViewExtension", () => { + it("registers database-view in the schema", () => { + const editor = new Editor({ + extensions: [StarterKit, DatabaseViewExtension], + content: "

hello

", + element: document.createElement("div"), + }); + + expect(editor.schema.nodes["database-view"]).toBeDefined(); + editor.destroy(); + }); + + it("accepts pre-inserted database-view node via HTML content", () => { + const editor = new Editor({ + extensions: [StarterKit, DatabaseViewExtension], + content: + '
', + element: document.createElement("div"), + }); + + const { doc } = editor.state; + let count = 0; + doc.descendants((node) => { + if (node.type.name === "database-view") { + count++; + expect(node.attrs.tableId).toBe("42"); + expect(node.attrs.viewId).toBe("7"); + expect(node.attrs.viewType).toBe("grid"); + } + }); + + expect(count).toBe(1); + editor.destroy(); + }); + + it("inserts a database-view node via the insertDatabaseView command", () => { + const editor = new Editor({ + extensions: [StarterKit, DatabaseViewExtension], + content: "

", + element: document.createElement("div"), + }); + + editor.commands.insertDatabaseView({ + tableId: "tbl-99", + viewId: "view-88", + viewType: "table", + bridgeUrl: "http://bridge.local:4000", + }); + + const { doc } = editor.state; + let found: ReturnType = null; + doc.descendants((node) => { + if (node.type.name === "database-view") found = node; + }); + + expect(found).not.toBeNull(); + expect((found as { attrs: { tableId: string } }).attrs.tableId).toBe("tbl-99"); + expect((found as { attrs: { bridgeUrl: string } }).attrs.bridgeUrl).toBe( + "http://bridge.local:4000", + ); + + editor.destroy(); + }); + + it("serialises and re-parses the node via getHTML / setContent round-trip", () => { + const editor = new Editor({ + extensions: [StarterKit, DatabaseViewExtension], + content: "

", + element: document.createElement("div"), + }); + + editor.commands.insertDatabaseView({ + tableId: "t-round", + viewId: "v-round", + viewType: "grid", + bridgeUrl: null, + }); + + const html = editor.getHTML(); + + // Re-create a fresh editor with the serialised HTML. + const editor2 = new Editor({ + extensions: [StarterKit, DatabaseViewExtension], + content: html, + element: document.createElement("div"), + }); + + let restoredAttrs: { tableId: string; viewId: string } | null = null; + editor2.state.doc.descendants((node) => { + if (node.type.name === "database-view") { + restoredAttrs = node.attrs as { tableId: string; viewId: string }; + } + }); + + expect(restoredAttrs).not.toBeNull(); + expect(restoredAttrs!.tableId).toBe("t-round"); + expect(restoredAttrs!.viewId).toBe("v-round"); + + editor.destroy(); + editor2.destroy(); + }); +}); diff --git a/apps/client/src/features/acadenice/database-view/__tests__/table-renderer.test.tsx b/apps/client/src/features/acadenice/database-view/__tests__/table-renderer.test.tsx new file mode 100644 index 00000000..00c2ee5b --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/__tests__/table-renderer.test.tsx @@ -0,0 +1,237 @@ +/** + * Tests for TableRenderer. + * + * Covers: + * - loading state (skeleton) + * - error states (generic, 403, 404) + * - empty state + * - data state (columns from fields, rows) + * - pagination controls + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MantineProvider } from "@mantine/core"; +import { TableRenderer } from "../renderers/table-renderer"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); + +// We mock the hooks to control the data returned to the renderer. +vi.mock("../hooks/use-view-data", () => ({ + VIEW_DATA_QUERY_KEY: "view-data", + useViewData: vi.fn(), +})); +vi.mock("../hooks/use-database-realtime-updates", () => ({ + useDatabaseRealtimeUpdates: vi.fn(), +})); + +import { useViewData } from "../hooks/use-view-data"; + +const mockUseViewData = useViewData as ReturnType; + +function Wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + {children} + + ); +} + +describe("TableRenderer", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows loading skeleton when isLoading is true", () => { + mockUseViewData.mockReturnValue({ + isLoading: true, + isError: false, + data: null, + error: null, + refetch: vi.fn(), + }); + render( + + + , + ); + // Skeleton renders multiple elements — just verify no table rendered. + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + }); + + it("shows generic error alert on failure", () => { + mockUseViewData.mockReturnValue({ + isLoading: false, + isError: true, + data: null, + error: { response: { status: 500 } }, + refetch: vi.fn(), + }); + render( + + + , + ); + expect( + screen.getByText("database_view.error.generic"), + ).toBeInTheDocument(); + }); + + it("shows permission_denied message on 403", () => { + mockUseViewData.mockReturnValue({ + isLoading: false, + isError: true, + data: null, + error: { response: { status: 403 } }, + refetch: vi.fn(), + }); + render( + + + , + ); + expect( + screen.getByText("database_view.error.permission_denied"), + ).toBeInTheDocument(); + }); + + it("shows view_not_found message on 404", () => { + mockUseViewData.mockReturnValue({ + isLoading: false, + isError: true, + data: null, + error: { response: { status: 404 } }, + refetch: vi.fn(), + }); + render( + + + , + ); + expect( + screen.getByText("database_view.error.view_not_found"), + ).toBeInTheDocument(); + }); + + it("shows empty state when rows are empty", () => { + mockUseViewData.mockReturnValue({ + isLoading: false, + isError: false, + data: { rows: [], fields: [], total: 0, hasNextPage: false }, + error: null, + refetch: vi.fn(), + }); + render( + + + , + ); + expect( + screen.getByText("database_view.table.empty_state"), + ).toBeInTheDocument(); + }); + + it("renders column headers from fields", () => { + mockUseViewData.mockReturnValue({ + isLoading: false, + isError: false, + data: { + rows: [ + { id: "r1", tableId: "t1", fields: { Name: "Alice", Age: "30" } }, + ], + fields: [ + { id: "Name", name: "Name", type: "text", primary: true }, + { id: "Age", name: "Age", type: "number" }, + ], + total: 1, + hasNextPage: false, + }, + error: null, + refetch: vi.fn(), + }); + render( + + + , + ); + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("Age")).toBeInTheDocument(); + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("30")).toBeInTheDocument(); + }); + + it("renders multiple rows", () => { + mockUseViewData.mockReturnValue({ + isLoading: false, + isError: false, + data: { + rows: [ + { id: "r1", tableId: "t1", fields: { Name: "Alice" } }, + { id: "r2", tableId: "t1", fields: { Name: "Bob" } }, + ], + fields: [{ id: "Name", name: "Name", type: "text", primary: true }], + total: 2, + hasNextPage: false, + }, + error: null, + refetch: vi.fn(), + }); + render( + + + , + ); + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("Bob")).toBeInTheDocument(); + }); + + it("shows pagination when hasNextPage is true and increments page on click", () => { + // First call returns page 1. + mockUseViewData.mockReturnValue({ + isLoading: false, + isError: false, + data: { + rows: [{ id: "r1", tableId: "t1", fields: { Name: "Alice" } }], + fields: [{ id: "Name", name: "Name", type: "text", primary: true }], + total: 100, + hasNextPage: true, + }, + error: null, + refetch: vi.fn(), + }); + render( + + + , + ); + const nextBtn = screen.getByText("database_view.table.next"); + expect(nextBtn).toBeInTheDocument(); + fireEvent.click(nextBtn); + // After click, useViewData should have been called again (via re-render + // with page=2). We verify by checking the mock call count increased. + expect(mockUseViewData).toHaveBeenCalledTimes(2); + const secondCall = mockUseViewData.mock.calls[1][0]; + expect(secondCall.page).toBe(2); + }); + + it("calls refetch when retry button is clicked on error", () => { + const refetch = vi.fn(); + mockUseViewData.mockReturnValue({ + isLoading: false, + isError: true, + data: null, + error: { response: { status: 500 } }, + refetch, + }); + render( + + + , + ); + fireEvent.click(screen.getByText("database_view.error.retry")); + expect(refetch).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/client/src/features/acadenice/database-view/__tests__/use-database-realtime-updates.test.ts b/apps/client/src/features/acadenice/database-view/__tests__/use-database-realtime-updates.test.ts new file mode 100644 index 00000000..c0e63702 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/__tests__/use-database-realtime-updates.test.ts @@ -0,0 +1,240 @@ +/** + * Tests for useDatabaseRealtimeUpdates SSE hook. + * + * Covers: + * - creates an EventSource with correct URL and credentials + * - invalidates React Query cache on matching row.updated event + * - ignores events from a different tableId + * - ignores events from a different viewId + * - closes the EventSource on unmount (cleanup) + * - does not create EventSource when tableId/viewId are undefined + * - handles malformed SSE data gracefully (no crash) + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; +import { useDatabaseRealtimeUpdates } from "../hooks/use-database-realtime-updates"; +import { VIEW_DATA_QUERY_KEY } from "../hooks/use-view-data"; + +// Stub EventSource globally for jsdom (native EventSource not in jsdom). +class MockEventSource { + url: string; + withCredentials: boolean; + readyState = 0; + private _listeners: Record void)[]> = {}; + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + + constructor(url: string, init?: { withCredentials?: boolean }) { + this.url = url; + this.withCredentials = init?.withCredentials ?? false; + instances.push(this); + } + + addEventListener(type: string, handler: (evt: MessageEvent) => void) { + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(handler); + } + + removeEventListener(type: string, handler: (evt: MessageEvent) => void) { + this._listeners[type] = (this._listeners[type] ?? []).filter( + (h) => h !== handler, + ); + } + + // Helper for tests to emit events. + emit(type: string, data: string) { + for (const handler of this._listeners[type] ?? []) { + handler({ data } as MessageEvent); + } + } + + emitOpen() { + this.readyState = 1; + for (const handler of this._listeners["open"] ?? []) { + handler({} as MessageEvent); + } + } + + emitError() { + for (const handler of this._listeners["error"] ?? []) { + handler({} as MessageEvent); + } + } + + close = vi.fn(() => { + this.readyState = 2; + }); +} + +const instances: MockEventSource[] = []; + +beforeEach(() => { + instances.length = 0; + vi.stubGlobal("EventSource", MockEventSource); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function makeWrapper(qc: QueryClient) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: qc }, children); + }; +} + +describe("useDatabaseRealtimeUpdates", () => { + it("creates an EventSource with withCredentials=true", () => { + const qc = new QueryClient(); + renderHook(() => useDatabaseRealtimeUpdates("t1", "v1"), { + wrapper: makeWrapper(qc), + }); + expect(instances).toHaveLength(1); + expect(instances[0].withCredentials).toBe(true); + expect(instances[0].url).toContain("/api/v1/events/sse"); + expect(instances[0].url).toContain("tables=t1"); + expect(instances[0].url).toContain("views=v1"); + }); + + it("invalidates view-data cache on row.updated event matching tableId+viewId", async () => { + const qc = new QueryClient(); + const invalidate = vi.spyOn(qc, "invalidateQueries"); + + renderHook(() => useDatabaseRealtimeUpdates("t1", "v1"), { + wrapper: makeWrapper(qc), + }); + + const es = instances[0]; + act(() => { + es.emit( + "row.updated", + JSON.stringify({ event: "row.updated", tableId: "t1", viewId: "v1" }), + ); + }); + + expect(invalidate).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: [VIEW_DATA_QUERY_KEY, "v1"], + exact: false, + }), + ); + }); + + it("also invalidates on generic 'message' event", async () => { + const qc = new QueryClient(); + const invalidate = vi.spyOn(qc, "invalidateQueries"); + + renderHook(() => useDatabaseRealtimeUpdates("t1", "v1"), { + wrapper: makeWrapper(qc), + }); + + const es = instances[0]; + act(() => { + es.emit( + "message", + JSON.stringify({ event: "row.created", tableId: "t1", viewId: "v1" }), + ); + }); + + expect(invalidate).toHaveBeenCalled(); + }); + + it("does NOT invalidate when tableId in event does not match", async () => { + const qc = new QueryClient(); + const invalidate = vi.spyOn(qc, "invalidateQueries"); + + renderHook(() => useDatabaseRealtimeUpdates("t1", "v1"), { + wrapper: makeWrapper(qc), + }); + + const es = instances[0]; + act(() => { + es.emit( + "row.updated", + JSON.stringify({ event: "row.updated", tableId: "t999", viewId: "v1" }), + ); + }); + + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("does NOT invalidate when viewId in event does not match", async () => { + const qc = new QueryClient(); + const invalidate = vi.spyOn(qc, "invalidateQueries"); + + renderHook(() => useDatabaseRealtimeUpdates("t1", "v1"), { + wrapper: makeWrapper(qc), + }); + + const es = instances[0]; + act(() => { + es.emit( + "row.updated", + JSON.stringify({ event: "row.updated", tableId: "t1", viewId: "v999" }), + ); + }); + + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("invalidates when no tableId/viewId in event payload (broadcast event)", async () => { + const qc = new QueryClient(); + const invalidate = vi.spyOn(qc, "invalidateQueries"); + + renderHook(() => useDatabaseRealtimeUpdates("t1", "v1"), { + wrapper: makeWrapper(qc), + }); + + const es = instances[0]; + act(() => { + // Broadcast event without tableId/viewId = affects all tables. + es.emit("row.updated", JSON.stringify({ event: "row.updated" })); + }); + + expect(invalidate).toHaveBeenCalled(); + }); + + it("does not crash on malformed SSE data", () => { + const qc = new QueryClient(); + renderHook(() => useDatabaseRealtimeUpdates("t1", "v1"), { + wrapper: makeWrapper(qc), + }); + + const es = instances[0]; + expect(() => { + act(() => { + es.emit("message", "not-valid-json{{{"); + }); + }).not.toThrow(); + }); + + it("closes EventSource on unmount", () => { + const qc = new QueryClient(); + const { unmount } = renderHook( + () => useDatabaseRealtimeUpdates("t1", "v1"), + { wrapper: makeWrapper(qc) }, + ); + + const es = instances[0]; + unmount(); + expect(es.close).toHaveBeenCalledOnce(); + }); + + it("does not create EventSource when tableId is undefined", () => { + const qc = new QueryClient(); + renderHook(() => useDatabaseRealtimeUpdates(undefined, "v1"), { + wrapper: makeWrapper(qc), + }); + expect(instances).toHaveLength(0); + }); + + it("does not create EventSource when viewId is undefined", () => { + const qc = new QueryClient(); + renderHook(() => useDatabaseRealtimeUpdates("t1", undefined), { + wrapper: makeWrapper(qc), + }); + expect(instances).toHaveLength(0); + }); +}); diff --git a/apps/client/src/features/acadenice/database-view/extension/database-view-component.tsx b/apps/client/src/features/acadenice/database-view/extension/database-view-component.tsx new file mode 100644 index 00000000..84871cfa --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/extension/database-view-component.tsx @@ -0,0 +1,55 @@ +import { NodeViewWrapper } from "@tiptap/react"; +import type { NodeViewProps } from "@tiptap/react"; +import { IconTable } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import clsx from "clsx"; +import { SUPPORTED_VIEW_TYPES } from "../types/database-view.types"; +import type { DatabaseViewAttrs, ViewType } from "../types/database-view.types"; +import { TableRenderer } from "../renderers/table-renderer"; +import { PlaceholderRenderer } from "../renderers/placeholder-renderer"; +import styles from "./database-view.module.css"; + +/** + * React NodeViewWrapper for the `database-view` Tiptap node. + * + * Dispatches on `attrs.viewType`: + * - "grid" | "table" -> TableRenderer (R3.1.c) + * - everything else -> PlaceholderRenderer (rendered in R3.1.d) + * + * Edit inline (row mutations) is intentionally absent — R3.1.c is read-only. + */ +export function DatabaseViewComponent({ node, selected }: NodeViewProps) { + const { t } = useTranslation(); + const attrs = node.attrs as DatabaseViewAttrs; + const { tableId, viewId, viewType, bridgeUrl } = attrs; + + const isSupported = SUPPORTED_VIEW_TYPES.includes(viewType as ViewType); + + return ( + +
+
+ + + + + {t("database_view.node.header_label")} + + {viewType} +
+ +
+ {isSupported ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/client/src/features/acadenice/database-view/extension/database-view-extension.ts b/apps/client/src/features/acadenice/database-view/extension/database-view-extension.ts new file mode 100644 index 00000000..8336a555 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/extension/database-view-extension.ts @@ -0,0 +1,112 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { DatabaseViewComponent } from "./database-view-component"; + +/** + * Tiptap Node extension: `database-view`. + * + * Renders an embedded Baserow view (grid/table in R3.1.c) inside a page. + * + * Attrs: + * - tableId : string, required — Baserow table ID + * - viewId : string, required — Baserow view ID + * - viewType : string, default "grid" — determines which renderer to use + * - bridgeUrl : string|null, default null — per-instance bridge URL override + * (falls back to VITE_BRIDGE_URL env var at runtime) + * + * The node is atomic (isLeaf, selectable) so it behaves like an image block. + * It is not editable in-place; mutations come via the bridge API in R3.1.d. + */ +const DatabaseViewExtension = Node.create({ + name: "database-view", + group: "block", + atom: true, + selectable: true, + draggable: true, + + addAttributes() { + return { + tableId: { + default: "", + parseHTML: (element) => element.getAttribute("data-table-id") ?? "", + renderHTML: (attributes) => ({ "data-table-id": attributes.tableId }), + }, + viewId: { + default: "", + parseHTML: (element) => element.getAttribute("data-view-id") ?? "", + renderHTML: (attributes) => ({ "data-view-id": attributes.viewId }), + }, + viewType: { + default: "grid", + parseHTML: (element) => + element.getAttribute("data-view-type") ?? "grid", + renderHTML: (attributes) => ({ + "data-view-type": attributes.viewType, + }), + }, + bridgeUrl: { + default: null, + parseHTML: (element) => + element.getAttribute("data-bridge-url") ?? null, + renderHTML: (attributes) => + attributes.bridgeUrl + ? { "data-bridge-url": attributes.bridgeUrl } + : {}, + }, + }; + }, + + parseHTML() { + return [{ tag: "div[data-node-type=database-view]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes(HTMLAttributes, { "data-node-type": "database-view" }), + ]; + }, + + addNodeView() { + return ReactNodeViewRenderer(DatabaseViewComponent); + }, + + addCommands() { + return { + insertDatabaseView: + (attrs: { + tableId: string; + viewId: string; + viewType: string; + bridgeUrl?: string | null; + }) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: { + tableId: attrs.tableId, + viewId: attrs.viewId, + viewType: attrs.viewType, + bridgeUrl: attrs.bridgeUrl ?? null, + }, + }); + }, + }; + }, +}); + +export default DatabaseViewExtension; + +/** Extend the Tiptap Commands interface for TypeScript consumers. */ +declare module "@tiptap/core" { + interface Commands { + databaseView: { + insertDatabaseView: (attrs: { + tableId: string; + viewId: string; + viewType: string; + bridgeUrl?: string | null; + }) => ReturnType; + }; + } +} diff --git a/apps/client/src/features/acadenice/database-view/extension/database-view.module.css b/apps/client/src/features/acadenice/database-view/extension/database-view.module.css new file mode 100644 index 00000000..0786975b --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/extension/database-view.module.css @@ -0,0 +1,67 @@ +.wrapper { + border: 1px solid var(--mantine-color-gray-3); + border-radius: var(--mantine-radius-sm); + overflow: hidden; + margin: 4px 0; + transition: border-color 100ms ease; + background-color: var(--mantine-color-white); +} + +[data-mantine-color-scheme="dark"] .wrapper { + border-color: var(--mantine-color-dark-4); + background-color: var(--mantine-color-dark-7); +} + +.wrapper:hover { + border-color: var(--mantine-color-gray-4); +} + +[data-mantine-color-scheme="dark"] .wrapper:hover { + border-color: var(--mantine-color-dark-3); +} + +/* ProseMirror node selection state */ +.wrapper.selected { + border-color: var(--mantine-color-blue-5); + box-shadow: 0 0 0 2px var(--mantine-color-blue-2); + outline: none; +} + +[data-mantine-color-scheme="dark"] .wrapper.selected { + border-color: var(--mantine-color-blue-4); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--mantine-color-blue-4) 30%, transparent); +} + +.header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--mantine-color-gray-2); + background-color: var(--mantine-color-gray-0); + font-size: var(--mantine-font-size-xs); + color: var(--mantine-color-gray-6); + font-weight: 500; +} + +[data-mantine-color-scheme="dark"] .header { + border-bottom-color: var(--mantine-color-dark-5); + background-color: var(--mantine-color-dark-6); + color: var(--mantine-color-dark-2); +} + +.headerIcon { + flex-shrink: 0; + color: var(--mantine-color-gray-5); +} + +.headerTitle { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.content { + padding: 0; +} diff --git a/apps/client/src/features/acadenice/database-view/hooks/use-database-realtime-updates.ts b/apps/client/src/features/acadenice/database-view/hooks/use-database-realtime-updates.ts new file mode 100644 index 00000000..424dcad6 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/hooks/use-database-realtime-updates.ts @@ -0,0 +1,134 @@ +import { useEffect, useRef } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { VIEW_DATA_QUERY_KEY } from "./use-view-data"; +import { resolveBridgeUrl } from "../services/bridge-client"; + +/** + * SSE consumer for realtime row/view updates from the bridge. + * + * Connects to `GET /api/v1/events/sse?tables=&views=` and + * listens for events whose type starts with `row.` or `view.`. On match, it + * invalidates the React Query cache for the affected view so the table + * re-fetches silently. + * + * SSE auth strategy: + * EventSource native does NOT support custom headers. The bridge is configured + * to accept the DocAdenice JWT via HttpOnly cookie (R2.3b), so credentials + * are sent automatically when the bridge is same-site. If your deployment + * serves the bridge on a different domain, either: + * - proxy /api/bridge/* through the Docmost Nginx so the cookie is same-site, or + * - switch to the `event-source-polyfill` package to inject an Authorization + * header (add it as a dep in package.json — R3.1.d decision). + * For now we use native EventSource with credentials. + * + * Reconnection: we implement exponential backoff (1s -> 2s -> 4s -> ... up to + * 30s) instead of relying on the browser's built-in reconnect because we want + * to filter by tableId/viewId and avoid reconnecting with a stale URL. + */ +export function useDatabaseRealtimeUpdates( + tableId: string | undefined, + viewId: string | undefined, + bridgeUrl?: string | null, +) { + const queryClient = useQueryClient(); + const esRef = useRef(null); + const retryDelayRef = useRef(1000); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + useEffect(() => { + if (!tableId || !viewId) return; + + const url = resolveBridgeUrl(bridgeUrl); + const sseUrl = `${url}/api/v1/events/sse?tables=${encodeURIComponent(tableId)}&views=${encodeURIComponent(viewId)}`; + + let retryTimeout: ReturnType | null = null; + + function connect() { + if (!isMountedRef.current) return; + + const es = new EventSource(sseUrl, { withCredentials: true }); + esRef.current = es; + + es.addEventListener("open", () => { + // Reset backoff on successful connection. + retryDelayRef.current = 1000; + }); + + // The bridge emits named events: `row.created`, `row.updated`, `row.deleted`, + // `view.updated`, etc. We listen on the generic `message` event as a catch-all + // and also register explicit named listeners below. + es.addEventListener("message", (evt) => { + handleEvent(evt.data); + }); + + const ROW_VIEW_EVENTS = [ + "row.created", + "row.updated", + "row.deleted", + "view.updated", + ]; + + for (const eventName of ROW_VIEW_EVENTS) { + es.addEventListener(eventName, (evt) => { + handleEvent((evt as MessageEvent).data); + }); + } + + es.addEventListener("error", () => { + es.close(); + esRef.current = null; + + if (!isMountedRef.current) return; + + // Exponential backoff capped at 30s. + const delay = Math.min(retryDelayRef.current, 30_000); + retryDelayRef.current = Math.min(retryDelayRef.current * 2, 30_000); + + retryTimeout = setTimeout(() => { + if (isMountedRef.current) connect(); + }, delay); + }); + } + + function handleEvent(rawData: string) { + // Filter by tableId/viewId: only invalidate if the event concerns us. + // Bridge payload: { event, tableId?, viewId?, rowId?, ... } + try { + const payload = JSON.parse(rawData) as { + tableId?: string; + viewId?: string; + event?: string; + }; + + const affectsOurTable = + !payload.tableId || payload.tableId === tableId; + const affectsOurView = !payload.viewId || payload.viewId === viewId; + + if (affectsOurTable && affectsOurView) { + // Invalidate all pages of this view's data. + queryClient.invalidateQueries({ + queryKey: [VIEW_DATA_QUERY_KEY, viewId], + exact: false, + }); + } + } catch { + // Unparseable SSE data — ignore rather than crash. + } + } + + connect(); + + return () => { + esRef.current?.close(); + esRef.current = null; + if (retryTimeout !== null) clearTimeout(retryTimeout); + }; + }, [tableId, viewId, bridgeUrl, queryClient]); +} diff --git a/apps/client/src/features/acadenice/database-view/hooks/use-tables.ts b/apps/client/src/features/acadenice/database-view/hooks/use-tables.ts new file mode 100644 index 00000000..39f64bdc --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/hooks/use-tables.ts @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import { getBridgeClient, resolveBridgeUrl } from "../services/bridge-client"; +import type { BridgeTable } from "../types/database-view.types"; + +export const TABLES_QUERY_KEY = ["bridge-tables"] as const; + +/** + * Fetches the list of tables exposed by the bridge. + * Used in the insert-database-modal step 1. + * + * The response is a flat array; pagination is not needed — Baserow tables per + * database are in the dozens, not thousands. + */ +export function useTables(bridgeUrl?: string | null) { + const url = resolveBridgeUrl(bridgeUrl); + + return useQuery({ + queryKey: [...TABLES_QUERY_KEY, url], + queryFn: async () => { + const client = getBridgeClient(url); + const res = await (client.get("/api/v1/tables") as unknown as Promise<{ + data: BridgeTable[]; + }>); + // Bridge wraps in { data: [...] } envelope. + return Array.isArray(res) ? res : (res as { data: BridgeTable[] }).data ?? []; + }, + staleTime: 30_000, + }); +} diff --git a/apps/client/src/features/acadenice/database-view/hooks/use-view-data.ts b/apps/client/src/features/acadenice/database-view/hooks/use-view-data.ts new file mode 100644 index 00000000..5a0583ea --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/hooks/use-view-data.ts @@ -0,0 +1,93 @@ +import { useQuery } from "@tanstack/react-query"; +import { getBridgeClient, resolveBridgeUrl } from "../services/bridge-client"; +import type { + BridgeField, + BridgeRow, + BridgeViewDataResponse, + ViewDataParams, +} from "../types/database-view.types"; + +export const VIEW_DATA_QUERY_KEY = "view-data"; + +export const viewDataQueryKey = ( + viewId: string, + page: number, + size: number, + bridgeUrl: string, +) => [VIEW_DATA_QUERY_KEY, viewId, page, size, bridgeUrl] as const; + +export interface UseViewDataResult { + rows: BridgeRow[]; + fields: BridgeField[]; + total: number; + hasNextPage: boolean; +} + +/** + * Fetches paginated rows + field schema for a given view. + * + * The bridge endpoint `GET /api/v1/views/:viewId/data` returns both the rows + * and the column definitions needed to build the table header. + * + * We keep fields + rows separate so the renderer can build TanStack Table + * column definitions from `fields` and row data from `rows`. + */ +export function useViewData({ + viewId, + bridgeUrl, + page = 1, + size = 50, +}: ViewDataParams) { + const url = resolveBridgeUrl(bridgeUrl); + + return useQuery({ + queryKey: viewDataQueryKey(viewId, page, size, url), + enabled: Boolean(viewId), + queryFn: async () => { + const client = getBridgeClient(url); + const res = await (client.get(`/api/v1/views/${viewId}/data`, { + params: { page, size }, + }) as unknown as Promise); + + // Normalise: bridge may return top-level array or wrapped envelope. + const rows: BridgeRow[] = Array.isArray(res) + ? (res as unknown as BridgeRow[]) + : res.data ?? []; + + // Field definitions come from the meta block or are inferred from rows. + // The bridge embeds fields in the response when the table has fields registered. + const fields: BridgeField[] = + (res as unknown as { fields?: BridgeField[] }).fields ?? + deriveFieldsFromRows(rows); + + const total: number = res.total ?? rows.length; + const hasNextPage: boolean = + res.meta?.hasNextPage ?? rows.length === size; + + return { rows, fields, total, hasNextPage }; + }, + staleTime: 10_000, + placeholderData: (prev) => prev, + }); +} + +/** + * When the bridge does not return explicit field metadata, infer column names + * from the union of all keys present across all row.fields objects. + * This is a degraded fallback — the bridge should always return fields. + */ +function deriveFieldsFromRows(rows: BridgeRow[]): BridgeField[] { + const keySet = new Set(); + for (const row of rows) { + for (const key of Object.keys(row.fields ?? {})) { + keySet.add(key); + } + } + return Array.from(keySet).map((k, i) => ({ + id: k, + name: k, + type: "text", + primary: i === 0, + options: null, + })); +} diff --git a/apps/client/src/features/acadenice/database-view/hooks/use-views.ts b/apps/client/src/features/acadenice/database-view/hooks/use-views.ts new file mode 100644 index 00000000..fd019196 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/hooks/use-views.ts @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; +import { getBridgeClient, resolveBridgeUrl } from "../services/bridge-client"; +import type { BridgeView } from "../types/database-view.types"; + +export const viewsQueryKey = (tableId: string, bridgeUrl: string) => + ["bridge-views", tableId, bridgeUrl] as const; + +/** + * Fetches the list of views for a given table. + * Used in the insert-database-modal step 2. + */ +export function useViews( + tableId: string | null | undefined, + bridgeUrl?: string | null, +) { + const url = resolveBridgeUrl(bridgeUrl); + + return useQuery({ + queryKey: viewsQueryKey(tableId ?? "", url), + enabled: Boolean(tableId), + queryFn: async () => { + const client = getBridgeClient(url); + const res = await (client.get( + `/api/v1/views/table/${tableId}`, + ) as unknown as Promise<{ data: BridgeView[] } | BridgeView[]>); + return Array.isArray(res) ? res : (res as { data: BridgeView[] }).data ?? []; + }, + staleTime: 30_000, + }); +} diff --git a/apps/client/src/features/acadenice/database-view/index.ts b/apps/client/src/features/acadenice/database-view/index.ts new file mode 100644 index 00000000..0271b9d8 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/index.ts @@ -0,0 +1,10 @@ +/** + * Public exports for the database-view feature (R3.1.c). + * + * Import DatabaseViewExtension in the editor extensions array. + * Import buildDatabaseSlashItem to register the slash command. + */ +export { default as DatabaseViewExtension } from "./extension/database-view-extension"; +export { buildDatabaseSlashItem } from "./slash-command/database-slash-command"; +export { useDatabaseRealtimeUpdates } from "./hooks/use-database-realtime-updates"; +export type { DatabaseViewAttrs, ViewType } from "./types/database-view.types"; diff --git a/apps/client/src/features/acadenice/database-view/renderers/placeholder-renderer.tsx b/apps/client/src/features/acadenice/database-view/renderers/placeholder-renderer.tsx new file mode 100644 index 00000000..042d4e5e --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/renderers/placeholder-renderer.tsx @@ -0,0 +1,27 @@ +import { Text, Stack, ThemeIcon } from "@mantine/core"; +import { IconTableOff } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import type { ViewType } from "../types/database-view.types"; + +interface PlaceholderRendererProps { + viewType: ViewType; +} + +/** + * Displayed for viewType values not yet implemented (kanban, calendar). + * These renderers arrive in R3.1.d. + */ +export function PlaceholderRenderer({ viewType }: PlaceholderRendererProps) { + const { t } = useTranslation(); + + return ( + + + + + + {t("database_view.placeholder.not_supported", { viewType })} + + + ); +} diff --git a/apps/client/src/features/acadenice/database-view/renderers/table-renderer.module.css b/apps/client/src/features/acadenice/database-view/renderers/table-renderer.module.css new file mode 100644 index 00000000..30856223 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/renderers/table-renderer.module.css @@ -0,0 +1,82 @@ +.wrapper { + overflow-x: auto; + width: 100%; + border-radius: var(--mantine-radius-sm); +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: var(--mantine-font-size-sm); +} + +.th { + padding: 6px 12px; + text-align: left; + font-weight: 600; + white-space: nowrap; + background-color: var(--mantine-color-gray-0); + border-bottom: 1px solid var(--mantine-color-gray-3); + color: var(--mantine-color-gray-7); +} + +[data-mantine-color-scheme="dark"] .th { + background-color: var(--mantine-color-dark-6); + border-bottom-color: var(--mantine-color-dark-4); + color: var(--mantine-color-dark-1); +} + +.tr { + border-bottom: 1px solid var(--mantine-color-gray-2); +} + +[data-mantine-color-scheme="dark"] .tr { + border-bottom-color: var(--mantine-color-dark-5); +} + +.tr:hover { + background-color: var(--mantine-color-gray-0); +} + +[data-mantine-color-scheme="dark"] .tr:hover { + background-color: var(--mantine-color-dark-6); +} + +.td { + padding: 6px 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 300px; + vertical-align: top; +} + +.emptyState { + padding: 24px; + text-align: center; +} + +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-top: 1px solid var(--mantine-color-gray-2); + font-size: var(--mantine-font-size-xs); + color: var(--mantine-color-gray-6); +} + +[data-mantine-color-scheme="dark"] .pagination { + border-top-color: var(--mantine-color-dark-5); + color: var(--mantine-color-dark-2); +} + +.skeleton { + width: 100%; +} + +.skeletonRow { + display: flex; + gap: 8px; + padding: 8px 12px; +} diff --git a/apps/client/src/features/acadenice/database-view/renderers/table-renderer.tsx b/apps/client/src/features/acadenice/database-view/renderers/table-renderer.tsx new file mode 100644 index 00000000..3e989090 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/renderers/table-renderer.tsx @@ -0,0 +1,203 @@ +import { useState } from "react"; +import { Text, Button, Group, Skeleton, Stack, Alert } from "@mantine/core"; +import { IconAlertCircle, IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useViewData } from "../hooks/use-view-data"; +import { useDatabaseRealtimeUpdates } from "../hooks/use-database-realtime-updates"; +import type { BridgeField, BridgeRow } from "../types/database-view.types"; +import styles from "./table-renderer.module.css"; + +/** + * NOTE: This renderer uses plain HTML table elements. + * + * TanStack Table v8 (@tanstack/react-table) is NOT yet installed in + * apps/client/package.json. The headless table logic here is a faithful + * placeholder that mirrors the TanStack Table v8 mental model (columns derived + * from BridgeField[], rows from BridgeRow[]) so migration is a drop-in. + * + * To migrate: install @tanstack/react-table@^8, then replace the manual + * column/row loops with useReactTable() + flexRender(). The data shape + * (fields + rows) is already correct. + * + * DEPENDENCY NEEDED: @tanstack/react-table@^8 + */ + +const PAGE_SIZE = 50; + +interface TableRendererProps { + tableId: string; + viewId: string; + bridgeUrl?: string | null; +} + +/** Display format for a raw cell value — keeps the table readable without edit. */ +function formatCellValue(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "object") { + // Arrays of strings/objects (select, link fields in Baserow) + if (Array.isArray(value)) { + return value + .map((v) => (typeof v === "object" && v !== null ? (v as { value?: string }).value ?? JSON.stringify(v) : String(v))) + .join(", "); + } + // Objects (file fields, etc.) + return (value as { value?: string }).value ?? JSON.stringify(value); + } + return String(value); +} + +/** Loading skeleton mimicking the table layout. */ +function TableSkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + + + +
+ ))} +
+ ); +} + +interface TableBodyProps { + fields: BridgeField[]; + rows: BridgeRow[]; +} + +function TableBody({ fields, rows }: TableBodyProps) { + const { t } = useTranslation(); + + if (rows.length === 0) { + return ( + + + + {t("database_view.table.empty_state")} + + + + ); + } + + return ( + <> + {rows.map((row) => ( + + {fields.map((field) => ( + + {formatCellValue(row.fields[field.name] ?? row.fields[field.id])} + + ))} + + ))} + + ); +} + +export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps) { + const { t } = useTranslation(); + const [page, setPage] = useState(1); + + const { data, isLoading, isError, error, refetch } = useViewData({ + viewId, + bridgeUrl, + page, + size: PAGE_SIZE, + }); + + // Subscribe to SSE updates — invalidates React Query cache on row/view events. + useDatabaseRealtimeUpdates(tableId, viewId, bridgeUrl); + + if (isLoading) { + return ; + } + + if (isError) { + const axiosError = error as { response?: { status?: number } }; + const status = axiosError?.response?.status; + + let message = t("database_view.error.generic"); + if (status === 403) message = t("database_view.error.permission_denied"); + else if (status === 404) message = t("database_view.error.view_not_found"); + + return ( + } + color="red" + title={t("database_view.error.title")} + > + + {message} + + + + ); + } + + const { rows, fields, total, hasNextPage } = data ?? { + rows: [], + fields: [], + total: 0, + hasNextPage: false, + }; + + return ( +
+
+ + + + {fields.map((field) => ( + + ))} + + + + + +
+ {field.name} +
+
+ + {/* Pagination — only shown when there is more than one page. */} + {(page > 1 || hasNextPage) && ( +
+ + {t("database_view.table.page_info", { + page, + total, + size: PAGE_SIZE, + })} + + + + + +
+ )} +
+ ); +} diff --git a/apps/client/src/features/acadenice/database-view/services/bridge-client.ts b/apps/client/src/features/acadenice/database-view/services/bridge-client.ts new file mode 100644 index 00000000..da59f41c --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/services/bridge-client.ts @@ -0,0 +1,83 @@ +/** + * Thin HTTP wrapper for the bridge API (R3.1.a). + * + * Why a separate client and not the shared `api` axios instance: + * The bridge lives at a different origin (VITE_BRIDGE_URL) and uses the + * DocAdenice JWT forwarded via the Authorization header, whereas `api` targets + * the Docmost backend at "/api" with cookie-based auth. + * + * The JWT is read from the cookie `authToken`. In production the cookie is + * HttpOnly so JS cannot read it — SSE auth uses the cookie automatically via + * credentials. For REST calls we rely on the cookie being sent automatically + * by withCredentials when the bridge is same-site, OR on the server proxying + * the calls through Docmost (future R3.2). For now we send credentials and + * leave the Authorization header empty when the token is not readable — the + * bridge falls back to cookie auth (R2.3b). + */ + +import axios, { AxiosInstance } from "axios"; + +/** Resolved bridge base URL: per-instance override > env var > default. */ +export function resolveBridgeUrl(bridgeUrlOverride?: string | null): string { + return ( + bridgeUrlOverride ?? + (typeof import.meta !== "undefined" && + (import.meta as Record).env && + ((import.meta as Record).env + .VITE_BRIDGE_URL as string)) ?? + "http://localhost:4000" + ); +} + +/** Attempt to read the auth token from JS-accessible cookie or jotai storage. */ +function readTokenFromCookie(): string | null { + try { + // authToken is HttpOnly in production; authTokens may be readable. + const raw = document.cookie + .split(";") + .map((c) => c.trim()) + .find((c) => c.startsWith("authTokens=")); + if (raw) { + const val = decodeURIComponent(raw.slice("authTokens=".length)); + const parsed = JSON.parse(val); + return typeof parsed?.token === "string" ? parsed.token : null; + } + } catch { + // cookie not accessible or malformed — silently fall through + } + return null; +} + +/** Build a one-shot axios instance targeting the resolved bridge URL. */ +export function createBridgeClient(bridgeUrl: string): AxiosInstance { + const instance = axios.create({ + baseURL: bridgeUrl, + withCredentials: true, + timeout: 15_000, + }); + + instance.interceptors.request.use((config) => { + const token = readTokenFromCookie(); + if (token) { + config.headers["Authorization"] = `Bearer ${token}`; + } + return config; + }); + + instance.interceptors.response.use( + (res) => res.data, + (err) => Promise.reject(err), + ); + + return instance; +} + +/** Singleton map: bridgeUrl -> axios instance (one per origin). */ +const _clients = new Map(); + +export function getBridgeClient(bridgeUrl: string): AxiosInstance { + if (!_clients.has(bridgeUrl)) { + _clients.set(bridgeUrl, createBridgeClient(bridgeUrl)); + } + return _clients.get(bridgeUrl)!; +} diff --git a/apps/client/src/features/acadenice/database-view/slash-command/database-slash-command.tsx b/apps/client/src/features/acadenice/database-view/slash-command/database-slash-command.tsx new file mode 100644 index 00000000..5941da46 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/slash-command/database-slash-command.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; +import type { Editor } from "@tiptap/core"; +import { Range } from "@tiptap/core"; +import { IconTable } from "@tabler/icons-react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MantineProvider } from "@mantine/core"; +import { InsertDatabaseModal } from "./insert-database-modal"; + +/** + * Slash command handler for `/database`. + * + * The handler is invoked synchronously by the Tiptap slash-command machinery + * (it receives editor + range, deletes the slash trigger text, then must open + * a modal). We render the modal as a portal outside the editor DOM via a + * React root mounted on document.body — the same technique Docmost uses for + * other slash commands that need a picker. + * + * Props passed through from the slash menu item command callback. + */ +interface DatabaseSlashCommandProps { + editor: Editor; + range: Range; + bridgeUrl?: string | null; +} + +/** + * Standalone component that renders the InsertDatabaseModal. + * Receives a `onDone` callback to tear down when done. + */ +export function DatabaseSlashCommandModal({ + editor, + bridgeUrl, + onDone, +}: Omit & { onDone: () => void }) { + const [opened, setOpened] = useState(true); + + function handleClose() { + setOpened(false); + onDone(); + } + + return ( + + ); +} + +/** + * Returns the slash menu item descriptor for the `/database` command. + * Import this and add it to the `CommandGroups` in menu-items.ts (upstream patch). + */ +export function buildDatabaseSlashItem(bridgeUrl?: string | null) { + return { + title: "Database view", + description: "Embed a Baserow table or view inline.", + searchTerms: [ + "database", + "table", + "baserow", + "view", + "grid", + "embed", + "data", + ], + icon: IconTable, + command: ({ + editor, + range, + }: { + editor: Editor; + range: Range; + }) => { + // Delete the slash trigger text before opening the modal. + editor.chain().focus().deleteRange(range).run(); + + // Mount a temporary React root for the modal — mirrors the Docmost pattern. + const container = document.createElement("div"); + document.body.appendChild(container); + + function teardown() { + // Defer so modal close animation finishes. + setTimeout(() => { + if (document.body.contains(container)) { + document.body.removeChild(container); + } + }, 300); + } + + // Lazy import to avoid circular deps at module init time. + import("react-dom/client").then(({ createRoot }) => { + // We need QueryClient + MantineProvider because this root is detached + // from the main app tree. Reuse existing providers when possible in + // future iterations; for now a fresh QueryClient is acceptable since + // this is a short-lived modal. + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const root = createRoot(container); + root.render( + + + { + root.unmount(); + teardown(); + }} + /> + + , + ); + }); + }, + }; +} diff --git a/apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.module.css b/apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.module.css new file mode 100644 index 00000000..98d2d497 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.module.css @@ -0,0 +1,87 @@ +.stepIndicator { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + font-size: var(--mantine-font-size-xs); + color: var(--mantine-color-gray-6); +} + +.stepDot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--mantine-color-gray-3); + flex-shrink: 0; +} + +.stepDot.active { + background-color: var(--mantine-color-blue-5); +} + +.tableList { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 300px; + overflow-y: auto; +} + +.tableItem { + padding: 8px 12px; + border: 1px solid var(--mantine-color-gray-2); + border-radius: var(--mantine-radius-sm); + cursor: pointer; + transition: background-color 80ms ease, border-color 80ms ease; + font-size: var(--mantine-font-size-sm); +} + +[data-mantine-color-scheme="dark"] .tableItem { + border-color: var(--mantine-color-dark-4); +} + +.tableItem:hover, +.tableItem.selected { + background-color: var(--mantine-color-blue-0); + border-color: var(--mantine-color-blue-3); +} + +[data-mantine-color-scheme="dark"] .tableItem:hover, +[data-mantine-color-scheme="dark"] .tableItem.selected { + background-color: color-mix(in srgb, var(--mantine-color-blue-9) 20%, transparent); + border-color: var(--mantine-color-blue-6); +} + +.viewBadge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border: 1px solid var(--mantine-color-gray-3); + border-radius: var(--mantine-radius-sm); + cursor: pointer; + font-size: var(--mantine-font-size-sm); + transition: background-color 80ms ease, border-color 80ms ease; +} + +[data-mantine-color-scheme="dark"] .viewBadge { + border-color: var(--mantine-color-dark-4); +} + +.viewBadge:hover, +.viewBadge.selected { + background-color: var(--mantine-color-blue-0); + border-color: var(--mantine-color-blue-3); +} + +[data-mantine-color-scheme="dark"] .viewBadge:hover, +[data-mantine-color-scheme="dark"] .viewBadge.selected { + background-color: color-mix(in srgb, var(--mantine-color-blue-9) 20%, transparent); + border-color: var(--mantine-color-blue-6); +} + +.viewGrid { + display: flex; + flex-wrap: wrap; + gap: 8px; +} diff --git a/apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.tsx b/apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.tsx new file mode 100644 index 00000000..5fb96d6d --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.tsx @@ -0,0 +1,273 @@ +import { useState } from "react"; +import { + Modal, + Text, + Stack, + Group, + Button, + Alert, + Loader, + TextInput, +} from "@mantine/core"; +import { IconAlertCircle, IconTable, IconChevronLeft } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import clsx from "clsx"; +import type { Editor } from "@tiptap/core"; +import { useTables } from "../hooks/use-tables"; +import { useViews } from "../hooks/use-views"; +import type { BridgeTable, BridgeView } from "../types/database-view.types"; +import styles from "./insert-database-modal.module.css"; + +type Step = "table" | "view"; + +interface InsertDatabaseModalProps { + opened: boolean; + onClose: () => void; + editor: Editor; + bridgeUrl?: string | null; +} + +/** + * Two-step Mantine modal to insert a database-view node. + * + * Step 1: pick a table from GET /api/v1/tables + * Step 2: pick a view from GET /api/v1/views/table/:tableId + * + * On confirmation, inserts the node via editor.commands.insertDatabaseView(). + */ +export function InsertDatabaseModal({ + opened, + onClose, + editor, + bridgeUrl, +}: InsertDatabaseModalProps) { + const { t } = useTranslation(); + + const [step, setStep] = useState("table"); + const [selectedTable, setSelectedTable] = useState(null); + const [selectedView, setSelectedView] = useState(null); + const [tableSearch, setTableSearch] = useState(""); + + const { + data: tables, + isLoading: tablesLoading, + isError: tablesError, + refetch: refetchTables, + } = useTables(bridgeUrl); + + const { + data: views, + isLoading: viewsLoading, + isError: viewsError, + refetch: refetchViews, + } = useViews(selectedTable?.id, bridgeUrl); + + function handleReset() { + setStep("table"); + setSelectedTable(null); + setSelectedView(null); + setTableSearch(""); + } + + function handleClose() { + handleReset(); + onClose(); + } + + function handleTableSelect(table: BridgeTable) { + setSelectedTable(table); + setSelectedView(null); + setStep("view"); + } + + function handleBack() { + setStep("table"); + setSelectedView(null); + } + + function handleInsert() { + if (!selectedTable || !selectedView) return; + + editor.commands.insertDatabaseView({ + tableId: selectedTable.id, + viewId: selectedView.id, + viewType: selectedView.type, + bridgeUrl: bridgeUrl ?? null, + }); + + handleClose(); + } + + const filteredTables = (tables ?? []).filter((t) => + t.name.toLowerCase().includes(tableSearch.toLowerCase()), + ); + + return ( + + + + {t("database_view.modal.title")} + + + } + size="md" + centered + > + {/* Step indicator */} +
+
+ + {t("database_view.modal.step1")} + +
+ + {t("database_view.modal.step2")} + +
+ + {step === "table" && ( + + setTableSearch(e.currentTarget.value)} + size="sm" + /> + + {tablesLoading && ( + + + + )} + + {tablesError && ( + } color="red"> + + {t("database_view.error.tables_load")} + + + + )} + + {!tablesLoading && !tablesError && ( +
+ {filteredTables.length === 0 && ( + + {t("database_view.modal.no_tables")} + + )} + {filteredTables.map((table) => ( +
handleTableSelect(table)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") handleTableSelect(table); + }} + > + + + {table.name} + +
+ ))} +
+ )} +
+ )} + + {step === "view" && ( + + + + + {selectedTable?.name} + + + + {viewsLoading && ( + + + + )} + + {viewsError && ( + } color="red"> + + {t("database_view.error.views_load")} + + + + )} + + {!viewsLoading && !viewsError && ( + + + {t("database_view.modal.select_view")} + +
+ {(views ?? []).length === 0 && ( + + {t("database_view.modal.no_views")} + + )} + {(views ?? []).map((view) => ( +
setSelectedView(view)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") setSelectedView(view); + }} + > + + {view.name} + + ({view.type}) + +
+ ))} +
+
+ )} + + + + + +
+ )} + + ); +} diff --git a/apps/client/src/features/acadenice/database-view/types/database-view.types.ts b/apps/client/src/features/acadenice/database-view/types/database-view.types.ts new file mode 100644 index 00000000..80b723ce --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/types/database-view.types.ts @@ -0,0 +1,77 @@ +/** + * Shared TypeScript types for the database-view Tiptap extension (R3.1.c). + * Aligned on bridge API contracts (R3.1.a). + */ + +/** View types the bridge exposes. Renderers for kanban/calendar arrive in R3.1.d. */ +export type ViewType = "grid" | "table" | "kanban" | "calendar" | string; + +/** Supported view types in R3.1.c. Others render a placeholder. */ +export const SUPPORTED_VIEW_TYPES: readonly ViewType[] = ["grid", "table"]; + +/** Attrs stored on the Tiptap node. */ +export interface DatabaseViewAttrs { + tableId: string; + viewId: string; + viewType: ViewType; + /** + * Optional per-instance bridge URL override. + * Falls back to the env var VITE_BRIDGE_URL when absent. + */ + bridgeUrl: string | null; +} + +/** A Baserow table descriptor returned by GET /api/v1/tables. */ +export interface BridgeTable { + id: string; + name: string; + databaseId?: string | number; + orderIndex?: number; +} + +/** A field descriptor returned embedded in GET /api/v1/tables/:id. */ +export interface BridgeField { + id: string; + name: string; + type: string; + primary?: boolean; + options?: Record | null; +} + +/** A view descriptor returned by GET /api/v1/views/table/:tableId. */ +export interface BridgeView { + id: string; + name: string; + type: ViewType; + tableId: string; +} + +/** A single row returned by GET /api/v1/views/:viewId/data. */ +export interface BridgeRow { + id: string; + tableId: string; + fields: Record; + createdOn?: string; + updatedOn?: string; + order?: string | number; +} + +/** Paginated response envelope from GET /api/v1/views/:viewId/data. */ +export interface BridgeViewDataResponse { + data: BridgeRow[]; + /** Total row count (unpaginated) — may be absent on older bridge versions. */ + total?: number; + meta?: { + page?: number; + size?: number; + hasNextPage?: boolean; + }; +} + +/** Paginated fetch params for the view-data hook. */ +export interface ViewDataParams { + viewId: string; + bridgeUrl?: string | null; + page?: number; + size?: number; +} diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 875e2efd..2b69aaed 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -52,8 +52,14 @@ import { VimeoIcon, YoutubeIcon, } from "@/components/icons"; +// Acadenice R3.1.c — database-view slash command +import { buildDatabaseSlashItem } from "@/features/acadenice/database-view"; const CommandGroups: SlashMenuGroupedItemsType = { + // Acadenice R3.1.c — database embed group (separate from basic to keep ordering clean) + acadenice: [ + buildDatabaseSlashItem() as unknown as import("./types").SlashMenuItemType, + ], basic: [ { title: "Text", diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 1ad93308..15f9ba79 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -100,6 +100,8 @@ import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboa import EmojiCommand from "./emoji-command"; import { countWords } from "alfaaz"; import AutoJoiner from "@/features/editor/extensions/autojoiner.ts"; +// Acadenice R3.1.c — database-view Tiptap node +import { DatabaseViewExtension } from "@/features/acadenice/database-view"; const lowlight = createLowlight(common); lowlight.register("mermaid", plaintext); @@ -378,6 +380,8 @@ export const mainExtensions = [ AutoJoiner.configure({ elementsToJoin: [], }), + // Acadenice R3.1.c — inline database-view block (Baserow table/view embed) + DatabaseViewExtension, ] as any; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];