From 43a70929ec11c5bc3b0c921d7d7d98154516779b Mon Sep 17 00:00:00 2001 From: Corentin Date: Mon, 11 May 2026 12:28:38 +0000 Subject: [PATCH] fix(database-view): pick a database before listing tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The insert-database modal called the public bridge /api/v1/tables route, which requires a databaseId and a Baserow user JWT — the modal supplied neither, so the request returned 400 then 501. Add a step 0 to pick a workspace and database (auto-resolved when each lists only one), then list tables via the admin endpoint GET /api/v1/admin/tables?databaseId=X. The client extension is also rewired to .extend() the shared DatabaseView node. --- .../__tests__/insert-database-modal.test.tsx | 65 ++++++- .../extension/database-view-extension.ts | 106 +---------- .../database-view/hooks/use-databases.ts | 19 ++ .../database-view/hooks/use-tables.ts | 39 ++-- .../database-view/hooks/use-workspaces.ts | 12 ++ .../database-view/services/admin-client.ts | 10 ++ .../slash-command/insert-database-modal.tsx | 168 +++++++++++++++++- 7 files changed, 290 insertions(+), 129 deletions(-) create mode 100644 apps/client/src/features/acadenice/database-view/hooks/use-databases.ts create mode 100644 apps/client/src/features/acadenice/database-view/hooks/use-workspaces.ts diff --git a/apps/client/src/features/acadenice/database-view/__tests__/insert-database-modal.test.tsx b/apps/client/src/features/acadenice/database-view/__tests__/insert-database-modal.test.tsx index f4c86d09..db453b45 100644 --- a/apps/client/src/features/acadenice/database-view/__tests__/insert-database-modal.test.tsx +++ b/apps/client/src/features/acadenice/database-view/__tests__/insert-database-modal.test.tsx @@ -31,11 +31,30 @@ vi.mock("../hooks/use-views", () => ({ viewsQueryKey: vi.fn(() => ["views"]), })); +vi.mock("../hooks/use-workspaces", () => ({ + useWorkspaces: vi.fn(), + WORKSPACES_QUERY_KEY: ["bridge-admin-workspaces"], +})); + +vi.mock("../hooks/use-databases", () => ({ + useDatabases: vi.fn(), + DATABASES_QUERY_KEY: ["bridge-admin-databases"], +})); + import { useTables } from "../hooks/use-tables"; import { useViews } from "../hooks/use-views"; +import { useWorkspaces } from "../hooks/use-workspaces"; +import { useDatabases } from "../hooks/use-databases"; const mockUseTables = useTables as ReturnType; const mockUseViews = useViews as ReturnType; +const mockUseWorkspaces = useWorkspaces as ReturnType; +const mockUseDatabases = useDatabases as ReturnType; + +const WORKSPACES = [{ id: 1, name: "WS" }]; +const DATABASES = [ + { id: 10, name: "MyDB", workspace: { id: 1, name: "WS" }, tables: [] }, +]; const TABLES = [ { id: "t1", name: "Contacts" }, @@ -79,9 +98,32 @@ describe("InsertDatabaseModal", () => { isError: false, refetch: vi.fn(), }); + mockUseWorkspaces.mockReturnValue({ + data: WORKSPACES, + isLoading: false, + isError: false, + refetch: vi.fn(), + }); + mockUseDatabases.mockReturnValue({ + data: DATABASES, + isLoading: false, + isError: false, + refetch: vi.fn(), + }); }); - it("renders step 1 with table list when opened", () => { + // Step 0 (source) auto-resolves workspace+database via useEffect when + // there is a single one of each, then the user clicks "next" to land on + // step 1 (table). Wait for the button to become enabled before clicking. + async function advanceToTableStep() { + await waitFor(() => { + const next = screen.getByTestId("source-next-btn") as HTMLButtonElement; + expect(next.disabled).toBe(false); + }); + fireEvent.click(screen.getByTestId("source-next-btn")); + } + + it("renders step 1 with table list when opened", async () => { const editor = makeEditor(); render( @@ -93,11 +135,12 @@ describe("InsertDatabaseModal", () => { , ); expect(screen.getByText("database_view.modal.title")).toBeInTheDocument(); + await advanceToTableStep(); expect(screen.getByText("Contacts")).toBeInTheDocument(); expect(screen.getByText("Projects")).toBeInTheDocument(); }); - it("shows loading state in step 1", () => { + it("shows loading state in step 1", async () => { mockUseTables.mockReturnValue({ data: undefined, isLoading: true, @@ -114,11 +157,12 @@ describe("InsertDatabaseModal", () => { /> , ); + await advanceToTableStep(); // No table items visible during loading. expect(screen.queryByText("Contacts")).not.toBeInTheDocument(); }); - it("shows error alert and retry button on tables load failure", () => { + it("shows error alert and retry button on tables load failure", async () => { const refetch = vi.fn(); mockUseTables.mockReturnValue({ data: undefined, @@ -136,6 +180,7 @@ describe("InsertDatabaseModal", () => { /> , ); + await advanceToTableStep(); expect( screen.getByText("database_view.error.tables_load"), ).toBeInTheDocument(); @@ -143,7 +188,7 @@ describe("InsertDatabaseModal", () => { expect(refetch).toHaveBeenCalledOnce(); }); - it("shows empty state when no tables", () => { + it("shows empty state when no tables", async () => { mockUseTables.mockReturnValue({ data: [], isLoading: false, @@ -160,6 +205,7 @@ describe("InsertDatabaseModal", () => { /> , ); + await advanceToTableStep(); expect( screen.getByText("database_view.modal.no_tables"), ).toBeInTheDocument(); @@ -176,9 +222,9 @@ describe("InsertDatabaseModal", () => { /> , ); + await advanceToTableStep(); fireEvent.click(screen.getByText("Contacts")); await waitFor(() => { - expect(screen.getByText("database_view.modal.step2")).toBeInTheDocument(); expect(screen.getByText("Grid view")).toBeInTheDocument(); }); }); @@ -194,9 +240,13 @@ describe("InsertDatabaseModal", () => { /> , ); + await advanceToTableStep(); fireEvent.click(screen.getByText("Contacts")); - await waitFor(() => screen.getByText("database_view.modal.back")); - fireEvent.click(screen.getByText("database_view.modal.back")); + await waitFor(() => screen.getByText("Grid view")); + // Two back buttons exist now: source<-table and table<-view. Click the + // first one rendered (which is the view-step header back button). + const backButtons = screen.getAllByText("database_view.modal.back"); + fireEvent.click(backButtons[0]); await waitFor(() => { expect(screen.getByText("Contacts")).toBeInTheDocument(); expect(screen.queryByText("Grid view")).not.toBeInTheDocument(); @@ -215,6 +265,7 @@ describe("InsertDatabaseModal", () => { /> , ); + await advanceToTableStep(); fireEvent.click(screen.getByText("Contacts")); await waitFor(() => screen.getByText("Grid view")); fireEvent.click(screen.getByText("Grid view")); diff --git a/apps/client/src/features/acadenice/database-view/extension/database-view-extension.ts b/apps/client/src/features/acadenice/database-view/extension/database-view-extension.ts index 8336a555..86f68925 100644 --- a/apps/client/src/features/acadenice/database-view/extension/database-view-extension.ts +++ b/apps/client/src/features/acadenice/database-view/extension/database-view-extension.ts @@ -1,112 +1,18 @@ -import { Node, mergeAttributes } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; +import { DatabaseView } from "@docmost/editor-ext"; import { DatabaseViewComponent } from "./database-view-component"; /** - * Tiptap Node extension: `database-view`. + * Client-side database-view extension. * - * 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. + * Extends the shared @docmost/editor-ext DatabaseView node (registered on the + * Hocuspocus server) to attach the React NodeView. The schema (attrs, parse, + * render) lives in the shared node so server collab saves don't strip it. */ -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" }), - ]; - }, - +const DatabaseViewExtension = DatabaseView.extend({ addNodeView() { return ReactNodeViewRenderer(DatabaseViewComponent); }, - - addCommands() { - return { - insertDatabaseView: - (attrs: { - tableId: string; - viewId: string; - viewType: string; - bridgeUrl?: string | null; - }) => - ({ commands }) => { - return commands.insertContent({ - type: this.name, - attrs: { - tableId: attrs.tableId, - viewId: attrs.viewId, - viewType: attrs.viewType, - bridgeUrl: attrs.bridgeUrl ?? null, - }, - }); - }, - }; - }, }); export default DatabaseViewExtension; - -/** Extend the Tiptap Commands interface for TypeScript consumers. */ -declare module "@tiptap/core" { - interface Commands { - databaseView: { - insertDatabaseView: (attrs: { - tableId: string; - viewId: string; - viewType: string; - bridgeUrl?: string | null; - }) => ReturnType; - }; - } -} diff --git a/apps/client/src/features/acadenice/database-view/hooks/use-databases.ts b/apps/client/src/features/acadenice/database-view/hooks/use-databases.ts new file mode 100644 index 00000000..8910fad4 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/hooks/use-databases.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import { listDatabases, type BaserowDatabase } from "../services/admin-client"; + +export const DATABASES_QUERY_KEY = ["bridge-admin-databases"] as const; + +export function useDatabases( + workspaceId?: number | null, + bridgeUrl?: string | null, +) { + return useQuery({ + queryKey: [...DATABASES_QUERY_KEY, workspaceId ?? null, bridgeUrl ?? null], + queryFn: () => { + if (!workspaceId) return Promise.resolve([] as BaserowDatabase[]); + return listDatabases(workspaceId, bridgeUrl); + }, + enabled: Boolean(workspaceId), + staleTime: 60_000, + }); +} diff --git a/apps/client/src/features/acadenice/database-view/hooks/use-tables.ts b/apps/client/src/features/acadenice/database-view/hooks/use-tables.ts index 39f64bdc..42f734c2 100644 --- a/apps/client/src/features/acadenice/database-view/hooks/use-tables.ts +++ b/apps/client/src/features/acadenice/database-view/hooks/use-tables.ts @@ -1,29 +1,36 @@ import { useQuery } from "@tanstack/react-query"; -import { getBridgeClient, resolveBridgeUrl } from "../services/bridge-client"; +import { listTables, type BaserowTableSummary } from "../services/admin-client"; import type { BridgeTable } from "../types/database-view.types"; -export const TABLES_QUERY_KEY = ["bridge-tables"] as const; +export const TABLES_QUERY_KEY = ["bridge-admin-tables"] as const; /** - * Fetches the list of tables exposed by the bridge. - * Used in the insert-database-modal step 1. + * Fetch tables of a Baserow database via the bridge admin endpoint. * - * The response is a flat array; pagination is not needed — Baserow tables per - * database are in the dozens, not thousands. + * The legacy `GET /api/v1/tables` requires a Baserow user JWT and a databaseId + * filter — neither was wired into the modal — so we list via the admin client + * (`GET /api/v1/admin/tables?databaseId=X`) which uses a service-account JWT. + * + * The query is disabled until a databaseId is provided. */ -export function useTables(bridgeUrl?: string | null) { - const url = resolveBridgeUrl(bridgeUrl); - +export function useTables( + databaseId?: number | null, + bridgeUrl?: string | null, +) { return useQuery({ - queryKey: [...TABLES_QUERY_KEY, url], + queryKey: [...TABLES_QUERY_KEY, databaseId ?? null, bridgeUrl ?? null], 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 ?? []; + if (!databaseId) return []; + const rows = await listTables(databaseId, bridgeUrl); + return rows.map( + (t: BaserowTableSummary): BridgeTable => ({ + id: String(t.id), + name: t.name, + databaseId: t.database_id, + }), + ); }, + enabled: Boolean(databaseId), staleTime: 30_000, }); } diff --git a/apps/client/src/features/acadenice/database-view/hooks/use-workspaces.ts b/apps/client/src/features/acadenice/database-view/hooks/use-workspaces.ts new file mode 100644 index 00000000..6e9d8611 --- /dev/null +++ b/apps/client/src/features/acadenice/database-view/hooks/use-workspaces.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { listWorkspaces, type BaserowWorkspace } from "../services/admin-client"; + +export const WORKSPACES_QUERY_KEY = ["bridge-admin-workspaces"] as const; + +export function useWorkspaces(bridgeUrl?: string | null) { + return useQuery({ + queryKey: [...WORKSPACES_QUERY_KEY, bridgeUrl ?? null], + queryFn: () => listWorkspaces(bridgeUrl), + staleTime: 60_000, + }); +} diff --git a/apps/client/src/features/acadenice/database-view/services/admin-client.ts b/apps/client/src/features/acadenice/database-view/services/admin-client.ts index 20c33dde..37074558 100644 --- a/apps/client/src/features/acadenice/database-view/services/admin-client.ts +++ b/apps/client/src/features/acadenice/database-view/services/admin-client.ts @@ -94,6 +94,16 @@ export async function createDatabase( ); } +export async function listTables( + databaseId: number, + bridgeUrl?: string | null, +): Promise { + const api = getBridgeClient(resolveBridgeUrl(bridgeUrl)); + return unwrap( + api.get(`/api/v1/admin/tables`, { params: { databaseId } }), + ); +} + export async function createTable( databaseId: number, name: string, diff --git a/apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.tsx b/apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.tsx index 05a08289..c02e0902 100644 --- a/apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.tsx +++ b/apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Modal, Text, @@ -23,11 +23,13 @@ import { useTables } from "../hooks/use-tables"; import { useViews } from "../hooks/use-views"; import { useViewData } from "../hooks/use-view-data"; import { useTimelineConfig } from "../hooks/use-timeline-config"; +import { useWorkspaces } from "../hooks/use-workspaces"; +import { useDatabases } from "../hooks/use-databases"; import { resolveBridgeUrl } from "../services/bridge-client"; import type { BridgeTable, BridgeView, BridgeField } from "../types/database-view.types"; import styles from "./insert-database-modal.module.css"; -type Step = "table" | "view" | "timeline-mapping"; +type Step = "source" | "table" | "view" | "timeline-mapping"; interface InsertDatabaseModalProps { opened: boolean; @@ -54,11 +56,39 @@ export function InsertDatabaseModal({ }: InsertDatabaseModalProps) { const { t } = useTranslation(); - const [step, setStep] = useState("table"); + const [step, setStep] = useState("source"); + const [workspaceId, setWorkspaceId] = useState(null); + const [databaseId, setDatabaseId] = useState(null); const [selectedTable, setSelectedTable] = useState(null); const [selectedView, setSelectedView] = useState(null); const [tableSearch, setTableSearch] = useState(""); + const { + data: workspaces, + isLoading: workspacesLoading, + isError: workspacesError, + refetch: refetchWorkspaces, + } = useWorkspaces(bridgeUrl); + + const { + data: databases, + isLoading: databasesLoading, + isError: databasesError, + refetch: refetchDatabases, + } = useDatabases(workspaceId, bridgeUrl); + + useEffect(() => { + if (!workspaceId && workspaces?.length === 1) { + setWorkspaceId(workspaces[0].id); + } + }, [workspaces, workspaceId]); + + useEffect(() => { + if (workspaceId && !databaseId && databases?.length === 1) { + setDatabaseId(databases[0].id); + } + }, [databases, databaseId, workspaceId]); + // Timeline column mapping state const [titleCol, setTitleCol] = useState(""); const [startCol, setStartCol] = useState(""); @@ -70,7 +100,7 @@ export function InsertDatabaseModal({ isLoading: tablesLoading, isError: tablesError, refetch: refetchTables, - } = useTables(bridgeUrl); + } = useTables(databaseId, bridgeUrl); const { data: views, @@ -100,7 +130,9 @@ export function InsertDatabaseModal({ }); function handleReset() { - setStep("table"); + setStep("source"); + setWorkspaceId(null); + setDatabaseId(null); setSelectedTable(null); setSelectedView(null); setTableSearch(""); @@ -124,9 +156,12 @@ export function InsertDatabaseModal({ function handleBack() { if (step === "timeline-mapping") { setStep("view"); - } else { + } else if (step === "view") { setStep("table"); setSelectedView(null); + } else if (step === "table") { + setStep("source"); + setSelectedTable(null); } } @@ -203,6 +238,10 @@ export function InsertDatabaseModal({ > {/* Step indicator */}
+
+ + {t("database_view.modal.step0", "Database")} +
{t("database_view.modal.step1")} @@ -221,9 +260,126 @@ export function InsertDatabaseModal({
+ {/* ---- STEP 0: source (workspace + database) ---- */} + {step === "source" && ( + + {workspacesLoading && ( + + + + )} + + {workspacesError && ( + } color="red"> + + + {t( + "database_view.error.workspaces_load", + "Failed to load workspaces from the bridge.", + )} + + + + + )} + + {!workspacesLoading && !workspacesError && (workspaces?.length ?? 0) > 1 && ( + ({ + value: String(db.id), + label: db.name, + }))} + value={databaseId ? String(databaseId) : null} + onChange={(v) => setDatabaseId(v ? Number(v) : null)} + required + aria-required="true" + disabled={(databases?.length ?? 0) === 0} + placeholder={ + (databases?.length ?? 0) === 0 + ? t("database_view.modal.no_databases", "No database in this workspace") + : undefined + } + data-testid="database-select" + /> + )} + + + + + + + )} + {/* ---- STEP 1: table selection ---- */} {step === "table" && ( + + + {databases?.find((db) => db.id === databaseId)?.name && ( + + {databases?.find((db) => db.id === databaseId)?.name} + + )} +