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 <noreply@anthropic.com>
This commit is contained in:
parent
4d8bd250be
commit
71c2abad8a
27 changed files with 2784 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) => {
|
||||
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;
|
||||
}) => (
|
||||
<div data-testid="table-renderer">
|
||||
table:{tableId}:{viewId}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock PlaceholderRenderer.
|
||||
vi.mock("../renderers/placeholder-renderer", () => ({
|
||||
PlaceholderRenderer: ({ viewType }: { viewType: string }) => (
|
||||
<div data-testid="placeholder-renderer">placeholder:{viewType}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
function makeNodeViewProps(
|
||||
attrs: Record<string, unknown>,
|
||||
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 (
|
||||
<QueryClientProvider client={qc}>
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("DatabaseViewComponent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders TableRenderer for viewType grid", () => {
|
||||
const props = makeNodeViewProps({
|
||||
tableId: "t1",
|
||||
viewId: "v1",
|
||||
viewType: "grid",
|
||||
bridgeUrl: null,
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<DatabaseViewComponent {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
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(
|
||||
<Wrapper>
|
||||
<DatabaseViewComponent {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
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(
|
||||
<Wrapper>
|
||||
<DatabaseViewComponent {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
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(
|
||||
<Wrapper>
|
||||
<DatabaseViewComponent {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.getByTestId("placeholder-renderer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the node header label", () => {
|
||||
const props = makeNodeViewProps({
|
||||
tableId: "t1",
|
||||
viewId: "v1",
|
||||
viewType: "grid",
|
||||
bridgeUrl: null,
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<DatabaseViewComponent {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
// The header label is the i18n key (mocked to return the key).
|
||||
expect(
|
||||
screen.getByText("database_view.node.header_label"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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: "<p></p>",
|
||||
// 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<string, string>])[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 =
|
||||
'<div data-node-type="database-view" data-table-id="42" data-view-id="7" data-view-type="table"></div>';
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof vi.fn>;
|
||||
const mockUseViews = useViews as ReturnType<typeof vi.fn>;
|
||||
|
||||
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 (
|
||||
<QueryClientProvider client={qc}>
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<InsertDatabaseModal
|
||||
opened={true}
|
||||
onClose={vi.fn()}
|
||||
editor={editor as never}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
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(
|
||||
<Wrapper>
|
||||
<InsertDatabaseModal
|
||||
opened={true}
|
||||
onClose={vi.fn()}
|
||||
editor={editor as never}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
// 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(
|
||||
<Wrapper>
|
||||
<InsertDatabaseModal
|
||||
opened={true}
|
||||
onClose={vi.fn()}
|
||||
editor={editor as never}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
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(
|
||||
<Wrapper>
|
||||
<InsertDatabaseModal
|
||||
opened={true}
|
||||
onClose={vi.fn()}
|
||||
editor={editor as never}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("database_view.modal.no_tables"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("moves to step 2 when a table is selected", async () => {
|
||||
const editor = makeEditor();
|
||||
render(
|
||||
<Wrapper>
|
||||
<InsertDatabaseModal
|
||||
opened={true}
|
||||
onClose={vi.fn()}
|
||||
editor={editor as never}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
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(
|
||||
<Wrapper>
|
||||
<InsertDatabaseModal
|
||||
opened={true}
|
||||
onClose={vi.fn()}
|
||||
editor={editor as never}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
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(
|
||||
<Wrapper>
|
||||
<InsertDatabaseModal
|
||||
opened={true}
|
||||
onClose={onClose}
|
||||
editor={editor as never}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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: "<p>hello</p>",
|
||||
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:
|
||||
'<div data-node-type="database-view" data-table-id="42" data-view-id="7" data-view-type="grid"></div>',
|
||||
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: "<p></p>",
|
||||
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<typeof doc.firstChild> = 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: "<p></p>",
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof vi.fn>;
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
// 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(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
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(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
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(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
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(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
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(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
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(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
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(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
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(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("database_view.error.retry"));
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, ((evt: MessageEvent) => 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<NodeViewWrapper>
|
||||
<div className={clsx(styles.wrapper, { [styles.selected]: selected })}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.headerIcon}>
|
||||
<IconTable size={14} />
|
||||
</span>
|
||||
<span className={styles.headerTitle}>
|
||||
{t("database_view.node.header_label")}
|
||||
</span>
|
||||
<span>{viewType}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{isSupported ? (
|
||||
<TableRenderer
|
||||
tableId={tableId}
|
||||
viewId={viewId}
|
||||
bridgeUrl={bridgeUrl}
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderRenderer viewType={viewType} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<ReturnType> {
|
||||
databaseView: {
|
||||
insertDatabaseView: (attrs: {
|
||||
tableId: string;
|
||||
viewId: string;
|
||||
viewType: string;
|
||||
bridgeUrl?: string | null;
|
||||
}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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=<tableId>&views=<viewId>` 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<EventSource | null>(null);
|
||||
const retryDelayRef = useRef<number>(1000);
|
||||
const isMountedRef = useRef<boolean>(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<typeof setTimeout> | 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]);
|
||||
}
|
||||
|
|
@ -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<BridgeTable[]>({
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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<UseViewDataResult>({
|
||||
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<BridgeViewDataResponse>);
|
||||
|
||||
// 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<string>();
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
|
@ -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<BridgeView[]>({
|
||||
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,
|
||||
});
|
||||
}
|
||||
10
apps/client/src/features/acadenice/database-view/index.ts
Normal file
10
apps/client/src/features/acadenice/database-view/index.ts
Normal file
|
|
@ -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";
|
||||
|
|
@ -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 (
|
||||
<Stack align="center" py="xl" gap="xs">
|
||||
<ThemeIcon variant="light" color="gray" size="lg">
|
||||
<IconTableOff size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("database_view.placeholder.not_supported", { viewType })}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className={styles.skeleton}>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className={styles.skeletonRow}>
|
||||
<Skeleton height={18} width="20%" />
|
||||
<Skeleton height={18} width="30%" />
|
||||
<Skeleton height={18} width="25%" />
|
||||
<Skeleton height={18} width="15%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableBodyProps {
|
||||
fields: BridgeField[];
|
||||
rows: BridgeRow[];
|
||||
}
|
||||
|
||||
function TableBody({ fields, rows }: TableBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={fields.length} className={styles.emptyState}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("database_view.table.empty_state")}
|
||||
</Text>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className={styles.tr}>
|
||||
{fields.map((field) => (
|
||||
<td key={field.id} className={styles.td}>
|
||||
{formatCellValue(row.fields[field.name] ?? row.fields[field.id])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 <TableSkeleton />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
color="red"
|
||||
title={t("database_view.error.title")}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">{message}</Text>
|
||||
<Button size="xs" variant="subtle" onClick={() => refetch()}>
|
||||
{t("database_view.error.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const { rows, fields, total, hasNextPage } = data ?? {
|
||||
rows: [],
|
||||
fields: [],
|
||||
total: 0,
|
||||
hasNextPage: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.wrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
{fields.map((field) => (
|
||||
<th key={field.id} className={styles.th}>
|
||||
{field.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<TableBody fields={fields} rows={rows} />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination — only shown when there is more than one page. */}
|
||||
{(page > 1 || hasNextPage) && (
|
||||
<div className={styles.pagination}>
|
||||
<Text size="xs">
|
||||
{t("database_view.table.page_info", {
|
||||
page,
|
||||
total,
|
||||
size: PAGE_SIZE,
|
||||
})}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
disabled={page === 1}
|
||||
leftSection={<IconChevronLeft size={14} />}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
{t("database_view.table.prev")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
disabled={!hasNextPage}
|
||||
rightSection={<IconChevronRight size={14} />}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
{t("database_view.table.next")}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, unknown>).env &&
|
||||
((import.meta as Record<string, { VITE_BRIDGE_URL?: string }>).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<string, AxiosInstance>();
|
||||
|
||||
export function getBridgeClient(bridgeUrl: string): AxiosInstance {
|
||||
if (!_clients.has(bridgeUrl)) {
|
||||
_clients.set(bridgeUrl, createBridgeClient(bridgeUrl));
|
||||
}
|
||||
return _clients.get(bridgeUrl)!;
|
||||
}
|
||||
|
|
@ -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<DatabaseSlashCommandProps, "range"> & { onDone: () => void }) {
|
||||
const [opened, setOpened] = useState(true);
|
||||
|
||||
function handleClose() {
|
||||
setOpened(false);
|
||||
onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<InsertDatabaseModal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
editor={editor}
|
||||
bridgeUrl={bridgeUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MantineProvider>
|
||||
<DatabaseSlashCommandModal
|
||||
editor={editor}
|
||||
bridgeUrl={bridgeUrl}
|
||||
onDone={() => {
|
||||
root.unmount();
|
||||
teardown();
|
||||
}}
|
||||
/>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<Step>("table");
|
||||
const [selectedTable, setSelectedTable] = useState<BridgeTable | null>(null);
|
||||
const [selectedView, setSelectedView] = useState<BridgeView | null>(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 (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<IconTable size={18} />
|
||||
<Text fw={600} size="sm">
|
||||
{t("database_view.modal.title")}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
size="md"
|
||||
centered
|
||||
>
|
||||
{/* Step indicator */}
|
||||
<div className={styles.stepIndicator}>
|
||||
<div className={clsx(styles.stepDot, { [styles.active]: step === "table" })} />
|
||||
<Text size="xs" c={step === "table" ? "blue" : "dimmed"}>
|
||||
{t("database_view.modal.step1")}
|
||||
</Text>
|
||||
<div className={clsx(styles.stepDot, { [styles.active]: step === "view" })} />
|
||||
<Text size="xs" c={step === "view" ? "blue" : "dimmed"}>
|
||||
{t("database_view.modal.step2")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{step === "table" && (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
placeholder={t("database_view.modal.search_tables")}
|
||||
value={tableSearch}
|
||||
onChange={(e) => setTableSearch(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{tablesLoading && (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{tablesError && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">{t("database_view.error.tables_load")}</Text>
|
||||
<Button size="xs" variant="subtle" onClick={() => refetchTables()}>
|
||||
{t("database_view.error.retry")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!tablesLoading && !tablesError && (
|
||||
<div className={styles.tableList}>
|
||||
{filteredTables.length === 0 && (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
{t("database_view.modal.no_tables")}
|
||||
</Text>
|
||||
)}
|
||||
{filteredTables.map((table) => (
|
||||
<div
|
||||
key={table.id}
|
||||
className={clsx(styles.tableItem, {
|
||||
[styles.selected]: selectedTable?.id === table.id,
|
||||
})}
|
||||
onClick={() => handleTableSelect(table)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") handleTableSelect(table);
|
||||
}}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconTable size={14} />
|
||||
<Text size="sm">{table.name}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{step === "view" && (
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconChevronLeft size={14} />}
|
||||
onClick={handleBack}
|
||||
>
|
||||
{t("database_view.modal.back")}
|
||||
</Button>
|
||||
<Text size="sm" fw={500}>
|
||||
{selectedTable?.name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{viewsLoading && (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{viewsError && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">{t("database_view.error.views_load")}</Text>
|
||||
<Button size="xs" variant="subtle" onClick={() => refetchViews()}>
|
||||
{t("database_view.error.retry")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!viewsLoading && !viewsError && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("database_view.modal.select_view")}
|
||||
</Text>
|
||||
<div className={styles.viewGrid}>
|
||||
{(views ?? []).length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("database_view.modal.no_views")}
|
||||
</Text>
|
||||
)}
|
||||
{(views ?? []).map((view) => (
|
||||
<div
|
||||
key={view.id}
|
||||
className={clsx(styles.viewBadge, {
|
||||
[styles.selected]: selectedView?.id === view.id,
|
||||
})}
|
||||
onClick={() => setSelectedView(view)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") setSelectedView(view);
|
||||
}}
|
||||
>
|
||||
<IconTable size={12} />
|
||||
<span>{view.name}</span>
|
||||
<Text size="xs" c="dimmed">
|
||||
({view.type})
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" size="sm" onClick={handleClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!selectedView}
|
||||
onClick={handleInsert}
|
||||
>
|
||||
{t("database_view.modal.insert")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, unknown> | 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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue