Compare commits

..

No commits in common. "43a70929ec11c5bc3b0c921d7d7d98154516779b" and "dbd79cc17c448bb6bb77d8754776011191982575" have entirely different histories.

14 changed files with 287 additions and 523 deletions

View file

@ -31,30 +31,11 @@ vi.mock("../hooks/use-views", () => ({
viewsQueryKey: vi.fn(() => ["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 { useTables } from "../hooks/use-tables";
import { useViews } from "../hooks/use-views"; import { useViews } from "../hooks/use-views";
import { useWorkspaces } from "../hooks/use-workspaces";
import { useDatabases } from "../hooks/use-databases";
const mockUseTables = useTables as ReturnType<typeof vi.fn>; const mockUseTables = useTables as ReturnType<typeof vi.fn>;
const mockUseViews = useViews as ReturnType<typeof vi.fn>; const mockUseViews = useViews as ReturnType<typeof vi.fn>;
const mockUseWorkspaces = useWorkspaces as ReturnType<typeof vi.fn>;
const mockUseDatabases = useDatabases as ReturnType<typeof vi.fn>;
const WORKSPACES = [{ id: 1, name: "WS" }];
const DATABASES = [
{ id: 10, name: "MyDB", workspace: { id: 1, name: "WS" }, tables: [] },
];
const TABLES = [ const TABLES = [
{ id: "t1", name: "Contacts" }, { id: "t1", name: "Contacts" },
@ -98,32 +79,9 @@ describe("InsertDatabaseModal", () => {
isError: false, isError: false,
refetch: vi.fn(), 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(),
});
}); });
// Step 0 (source) auto-resolves workspace+database via useEffect when it("renders step 1 with table list when opened", () => {
// 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(); const editor = makeEditor();
render( render(
<Wrapper> <Wrapper>
@ -135,12 +93,11 @@ describe("InsertDatabaseModal", () => {
</Wrapper>, </Wrapper>,
); );
expect(screen.getByText("database_view.modal.title")).toBeInTheDocument(); expect(screen.getByText("database_view.modal.title")).toBeInTheDocument();
await advanceToTableStep();
expect(screen.getByText("Contacts")).toBeInTheDocument(); expect(screen.getByText("Contacts")).toBeInTheDocument();
expect(screen.getByText("Projects")).toBeInTheDocument(); expect(screen.getByText("Projects")).toBeInTheDocument();
}); });
it("shows loading state in step 1", async () => { it("shows loading state in step 1", () => {
mockUseTables.mockReturnValue({ mockUseTables.mockReturnValue({
data: undefined, data: undefined,
isLoading: true, isLoading: true,
@ -157,12 +114,11 @@ describe("InsertDatabaseModal", () => {
/> />
</Wrapper>, </Wrapper>,
); );
await advanceToTableStep();
// No table items visible during loading. // No table items visible during loading.
expect(screen.queryByText("Contacts")).not.toBeInTheDocument(); expect(screen.queryByText("Contacts")).not.toBeInTheDocument();
}); });
it("shows error alert and retry button on tables load failure", async () => { it("shows error alert and retry button on tables load failure", () => {
const refetch = vi.fn(); const refetch = vi.fn();
mockUseTables.mockReturnValue({ mockUseTables.mockReturnValue({
data: undefined, data: undefined,
@ -180,7 +136,6 @@ describe("InsertDatabaseModal", () => {
/> />
</Wrapper>, </Wrapper>,
); );
await advanceToTableStep();
expect( expect(
screen.getByText("database_view.error.tables_load"), screen.getByText("database_view.error.tables_load"),
).toBeInTheDocument(); ).toBeInTheDocument();
@ -188,7 +143,7 @@ describe("InsertDatabaseModal", () => {
expect(refetch).toHaveBeenCalledOnce(); expect(refetch).toHaveBeenCalledOnce();
}); });
it("shows empty state when no tables", async () => { it("shows empty state when no tables", () => {
mockUseTables.mockReturnValue({ mockUseTables.mockReturnValue({
data: [], data: [],
isLoading: false, isLoading: false,
@ -205,7 +160,6 @@ describe("InsertDatabaseModal", () => {
/> />
</Wrapper>, </Wrapper>,
); );
await advanceToTableStep();
expect( expect(
screen.getByText("database_view.modal.no_tables"), screen.getByText("database_view.modal.no_tables"),
).toBeInTheDocument(); ).toBeInTheDocument();
@ -222,9 +176,9 @@ describe("InsertDatabaseModal", () => {
/> />
</Wrapper>, </Wrapper>,
); );
await advanceToTableStep();
fireEvent.click(screen.getByText("Contacts")); fireEvent.click(screen.getByText("Contacts"));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("database_view.modal.step2")).toBeInTheDocument();
expect(screen.getByText("Grid view")).toBeInTheDocument(); expect(screen.getByText("Grid view")).toBeInTheDocument();
}); });
}); });
@ -240,13 +194,9 @@ describe("InsertDatabaseModal", () => {
/> />
</Wrapper>, </Wrapper>,
); );
await advanceToTableStep();
fireEvent.click(screen.getByText("Contacts")); fireEvent.click(screen.getByText("Contacts"));
await waitFor(() => screen.getByText("Grid view")); await waitFor(() => screen.getByText("database_view.modal.back"));
// Two back buttons exist now: source<-table and table<-view. Click the fireEvent.click(screen.getByText("database_view.modal.back"));
// 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(() => { await waitFor(() => {
expect(screen.getByText("Contacts")).toBeInTheDocument(); expect(screen.getByText("Contacts")).toBeInTheDocument();
expect(screen.queryByText("Grid view")).not.toBeInTheDocument(); expect(screen.queryByText("Grid view")).not.toBeInTheDocument();
@ -265,7 +215,6 @@ describe("InsertDatabaseModal", () => {
/> />
</Wrapper>, </Wrapper>,
); );
await advanceToTableStep();
fireEvent.click(screen.getByText("Contacts")); fireEvent.click(screen.getByText("Contacts"));
await waitFor(() => screen.getByText("Grid view")); await waitFor(() => screen.getByText("Grid view"));
fireEvent.click(screen.getByText("Grid view")); fireEvent.click(screen.getByText("Grid view"));

View file

@ -1,18 +1,112 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { DatabaseView } from "@docmost/editor-ext";
import { DatabaseViewComponent } from "./database-view-component"; import { DatabaseViewComponent } from "./database-view-component";
/** /**
* Client-side database-view extension. * Tiptap Node extension: `database-view`.
* *
* Extends the shared @docmost/editor-ext DatabaseView node (registered on the * Renders an embedded Baserow view (grid/table in R3.1.c) inside a page.
* 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. * 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 = DatabaseView.extend({ 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() { addNodeView() {
return ReactNodeViewRenderer(DatabaseViewComponent); 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; 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;
};
}
}

View file

@ -1,19 +0,0 @@
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<BaserowDatabase[]>({
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,
});
}

View file

@ -1,36 +1,29 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { listTables, type BaserowTableSummary } from "../services/admin-client"; import { getBridgeClient, resolveBridgeUrl } from "../services/bridge-client";
import type { BridgeTable } from "../types/database-view.types"; import type { BridgeTable } from "../types/database-view.types";
export const TABLES_QUERY_KEY = ["bridge-admin-tables"] as const; export const TABLES_QUERY_KEY = ["bridge-tables"] as const;
/** /**
* Fetch tables of a Baserow database via the bridge admin endpoint. * Fetches the list of tables exposed by the bridge.
* Used in the insert-database-modal step 1.
* *
* The legacy `GET /api/v1/tables` requires a Baserow user JWT and a databaseId * The response is a flat array; pagination is not needed Baserow tables per
* filter neither was wired into the modal so we list via the admin client * database are in the dozens, not thousands.
* (`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( export function useTables(bridgeUrl?: string | null) {
databaseId?: number | null, const url = resolveBridgeUrl(bridgeUrl);
bridgeUrl?: string | null,
) {
return useQuery<BridgeTable[]>({ return useQuery<BridgeTable[]>({
queryKey: [...TABLES_QUERY_KEY, databaseId ?? null, bridgeUrl ?? null], queryKey: [...TABLES_QUERY_KEY, url],
queryFn: async () => { queryFn: async () => {
if (!databaseId) return []; const client = getBridgeClient(url);
const rows = await listTables(databaseId, bridgeUrl); const res = await (client.get("/api/v1/tables") as unknown as Promise<{
return rows.map( data: BridgeTable[];
(t: BaserowTableSummary): BridgeTable => ({ }>);
id: String(t.id), // Bridge wraps in { data: [...] } envelope.
name: t.name, return Array.isArray(res) ? res : (res as { data: BridgeTable[] }).data ?? [];
databaseId: t.database_id,
}),
);
}, },
enabled: Boolean(databaseId),
staleTime: 30_000, staleTime: 30_000,
}); });
} }

View file

@ -1,12 +0,0 @@
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<BaserowWorkspace[]>({
queryKey: [...WORKSPACES_QUERY_KEY, bridgeUrl ?? null],
queryFn: () => listWorkspaces(bridgeUrl),
staleTime: 60_000,
});
}

View file

@ -94,16 +94,6 @@ export async function createDatabase(
); );
} }
export async function listTables(
databaseId: number,
bridgeUrl?: string | null,
): Promise<BaserowTableSummary[]> {
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
return unwrap<BaserowTableSummary[]>(
api.get(`/api/v1/admin/tables`, { params: { databaseId } }),
);
}
export async function createTable( export async function createTable(
databaseId: number, databaseId: number,
name: string, name: string,

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { import {
Modal, Modal,
Text, Text,
@ -23,13 +23,11 @@ import { useTables } from "../hooks/use-tables";
import { useViews } from "../hooks/use-views"; import { useViews } from "../hooks/use-views";
import { useViewData } from "../hooks/use-view-data"; import { useViewData } from "../hooks/use-view-data";
import { useTimelineConfig } from "../hooks/use-timeline-config"; 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 { resolveBridgeUrl } from "../services/bridge-client";
import type { BridgeTable, BridgeView, BridgeField } from "../types/database-view.types"; import type { BridgeTable, BridgeView, BridgeField } from "../types/database-view.types";
import styles from "./insert-database-modal.module.css"; import styles from "./insert-database-modal.module.css";
type Step = "source" | "table" | "view" | "timeline-mapping"; type Step = "table" | "view" | "timeline-mapping";
interface InsertDatabaseModalProps { interface InsertDatabaseModalProps {
opened: boolean; opened: boolean;
@ -56,39 +54,11 @@ export function InsertDatabaseModal({
}: InsertDatabaseModalProps) { }: InsertDatabaseModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [step, setStep] = useState<Step>("source"); const [step, setStep] = useState<Step>("table");
const [workspaceId, setWorkspaceId] = useState<number | null>(null);
const [databaseId, setDatabaseId] = useState<number | null>(null);
const [selectedTable, setSelectedTable] = useState<BridgeTable | null>(null); const [selectedTable, setSelectedTable] = useState<BridgeTable | null>(null);
const [selectedView, setSelectedView] = useState<BridgeView | null>(null); const [selectedView, setSelectedView] = useState<BridgeView | null>(null);
const [tableSearch, setTableSearch] = useState(""); 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 // Timeline column mapping state
const [titleCol, setTitleCol] = useState(""); const [titleCol, setTitleCol] = useState("");
const [startCol, setStartCol] = useState(""); const [startCol, setStartCol] = useState("");
@ -100,7 +70,7 @@ export function InsertDatabaseModal({
isLoading: tablesLoading, isLoading: tablesLoading,
isError: tablesError, isError: tablesError,
refetch: refetchTables, refetch: refetchTables,
} = useTables(databaseId, bridgeUrl); } = useTables(bridgeUrl);
const { const {
data: views, data: views,
@ -130,9 +100,7 @@ export function InsertDatabaseModal({
}); });
function handleReset() { function handleReset() {
setStep("source"); setStep("table");
setWorkspaceId(null);
setDatabaseId(null);
setSelectedTable(null); setSelectedTable(null);
setSelectedView(null); setSelectedView(null);
setTableSearch(""); setTableSearch("");
@ -156,12 +124,9 @@ export function InsertDatabaseModal({
function handleBack() { function handleBack() {
if (step === "timeline-mapping") { if (step === "timeline-mapping") {
setStep("view"); setStep("view");
} else if (step === "view") { } else {
setStep("table"); setStep("table");
setSelectedView(null); setSelectedView(null);
} else if (step === "table") {
setStep("source");
setSelectedTable(null);
} }
} }
@ -238,10 +203,6 @@ export function InsertDatabaseModal({
> >
{/* Step indicator */} {/* Step indicator */}
<div className={styles.stepIndicator}> <div className={styles.stepIndicator}>
<div className={clsx(styles.stepDot, { [styles.active]: step === "source" })} />
<Text size="xs" c={step === "source" ? "blue" : "dimmed"}>
{t("database_view.modal.step0", "Database")}
</Text>
<div className={clsx(styles.stepDot, { [styles.active]: step === "table" })} /> <div className={clsx(styles.stepDot, { [styles.active]: step === "table" })} />
<Text size="xs" c={step === "table" ? "blue" : "dimmed"}> <Text size="xs" c={step === "table" ? "blue" : "dimmed"}>
{t("database_view.modal.step1")} {t("database_view.modal.step1")}
@ -260,126 +221,9 @@ export function InsertDatabaseModal({
</Text> </Text>
</div> </div>
{/* ---- STEP 0: source (workspace + database) ---- */}
{step === "source" && (
<Stack gap="sm">
{workspacesLoading && (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
)}
{workspacesError && (
<Alert icon={<IconAlertCircle size={16} />} color="red">
<Group justify="space-between">
<Text size="sm">
{t(
"database_view.error.workspaces_load",
"Failed to load workspaces from the bridge.",
)}
</Text>
<Button size="xs" variant="subtle" onClick={() => refetchWorkspaces()}>
{t("database_view.error.retry")}
</Button>
</Group>
</Alert>
)}
{!workspacesLoading && !workspacesError && (workspaces?.length ?? 0) > 1 && (
<Select
label={t("database_view.modal.workspace", "Workspace")}
data={(workspaces ?? []).map((ws) => ({
value: String(ws.id),
label: ws.name,
}))}
value={workspaceId ? String(workspaceId) : null}
onChange={(v) => {
setWorkspaceId(v ? Number(v) : null);
setDatabaseId(null);
}}
required
aria-required="true"
data-testid="workspace-select"
/>
)}
{workspaceId && databasesLoading && (
<Group justify="center" py="xs">
<Loader size="xs" />
</Group>
)}
{workspaceId && databasesError && (
<Alert icon={<IconAlertCircle size={16} />} color="red">
<Group justify="space-between">
<Text size="sm">
{t(
"database_view.error.databases_load",
"Failed to load databases from the bridge.",
)}
</Text>
<Button size="xs" variant="subtle" onClick={() => refetchDatabases()}>
{t("database_view.error.retry")}
</Button>
</Group>
</Alert>
)}
{workspaceId && !databasesLoading && !databasesError && (
<Select
label={t("database_view.modal.database", "Database")}
data={(databases ?? []).map((db) => ({
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"
/>
)}
<Group justify="flex-end" mt="sm">
<Button variant="default" size="sm" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button
size="sm"
disabled={!databaseId}
onClick={() => setStep("table")}
data-testid="source-next-btn"
>
{t("database_view.modal.next")}
</Button>
</Group>
</Stack>
)}
{/* ---- STEP 1: table selection ---- */} {/* ---- STEP 1: table selection ---- */}
{step === "table" && ( {step === "table" && (
<Stack gap="sm"> <Stack gap="sm">
<Group gap="xs">
<Button
size="xs"
variant="subtle"
leftSection={<IconChevronLeft size={14} />}
onClick={handleBack}
>
{t("database_view.modal.back")}
</Button>
{databases?.find((db) => db.id === databaseId)?.name && (
<Text size="xs" c="dimmed">
{databases?.find((db) => db.id === databaseId)?.name}
</Text>
)}
</Group>
<TextInput <TextInput
placeholder={t("database_view.modal.search_tables")} placeholder={t("database_view.modal.search_tables")}
value={tableSearch} value={tableSearch}

View file

@ -47,18 +47,14 @@ describe('WikilinkExtension schema', () => {
editor.destroy(); editor.destroy();
}); });
it('has attrs: pageId, slugId, spaceSlug, title, alias', () => { it('has attrs: pageId, title, alias', () => {
const editor = makeEditor(); const editor = makeEditor();
const nodeSpec = editor.schema.nodes.wikilink; const nodeSpec = editor.schema.nodes.wikilink;
const attrs = nodeSpec.spec.attrs as Record<string, { default: any }>; const attrs = nodeSpec.spec.attrs as Record<string, { default: any }>;
expect(attrs).toHaveProperty('pageId'); expect(attrs).toHaveProperty('pageId');
expect(attrs).toHaveProperty('slugId');
expect(attrs).toHaveProperty('spaceSlug');
expect(attrs).toHaveProperty('title'); expect(attrs).toHaveProperty('title');
expect(attrs).toHaveProperty('alias'); expect(attrs).toHaveProperty('alias');
expect(attrs.pageId.default).toBeNull(); expect(attrs.pageId.default).toBeNull();
expect(attrs.slugId.default).toBeNull();
expect(attrs.spaceSlug.default).toBeNull();
expect(attrs.alias.default).toBeNull(); expect(attrs.alias.default).toBeNull();
editor.destroy(); editor.destroy();
}); });

View file

@ -1,37 +1,147 @@
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'; import {
import type { NodeViewProps } from '@tiptap/core'; Node,
import { PluginKey } from '@tiptap/pm/state'; nodeInputRule,
type NodeViewRendererProps,
} from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Suggestion, type SuggestionOptions } from '@tiptap/suggestion'; import { Suggestion, type SuggestionOptions } from '@tiptap/suggestion';
import { useNavigate } from 'react-router-dom';
import { WikilinkNode, type WikilinkAttrs } from '@docmost/editor-ext';
import { buildPageUrl } from '@/features/page/page.utils.ts';
import { renderWikilinkSuggestion } from './wikilink-suggestion'; import { renderWikilinkSuggestion } from './wikilink-suggestion';
export type { WikilinkAttrs }; /**
* Wikilink Tiptap extension (R3.2).
*
* Implements the Obsidian-style [[Page Title]] and [[Page Title|alias]] syntax.
*
* Node attrs:
* - pageId : resolved UUID of the target page (null when unresolved)
* - title : canonical title of the target page
* - alias : optional display alias (text shown in editor)
*
* Rendering:
* - React NodeView: a styled link chip.
* - Unresolved (pageId === null): applies 'broken-wikilink' class (red / italic).
*
* Input rule:
* - Typing [[ opens the suggestion popup (reuses Tiptap Suggestion).
* - Pressing Esc or selecting a page closes the popup and inserts the node.
*
* The suggestion popup searches pages via `GET /api/search/suggestions?q=...`
* (same endpoint used by the native @mention system).
*/
export interface WikilinkAttrs {
pageId: string | null;
title: string;
alias: string | null;
}
const WIKILINK_INPUT_REGEX = /\[\[$/;
const WIKILINK_PARSE_REGEX = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/;
declare module '@tiptap/core' {
interface Commands<ReturnType> {
wikilink: {
/**
* Insert a wikilink node at the current cursor position.
*/
insertWikilink: (attrs: WikilinkAttrs) => ReturnType;
};
}
}
/** /**
* Wikilink client extension. * The wikilink node itself.
* *
* Extends the shared @docmost/editor-ext WikilinkNode (registered on the * It is `inline` and `atom` (cannot be entered the cursor moves around it).
* Hocuspocus server too without that, collab strips unknown nodes) to add: * This mirrors how Docmost handles mentions.
* - a React NodeView (rendered chip + click navigation)
* - a Suggestion plugin triggered by [[
*
* Navigation uses buildPageUrl(spaceSlug, slugId, title) so links land on the
* real /s/<space>/p/<slug-id> route. spaceSlug/slugId are written into node
* attrs at insert time (resolved from the search suggestion).
*/ */
export const WikilinkExtension = WikilinkNode.extend<{ export const WikilinkExtension = Node.create<{
suggestion: Partial<SuggestionOptions>; suggestion: Partial<SuggestionOptions>;
}>({ }>({
addOptions() { name: 'wikilink',
return { suggestion: {} };
group: 'inline',
inline: true,
atom: true,
selectable: true,
addAttributes() {
return {
pageId: {
default: null,
parseHTML: (el) => el.getAttribute('data-page-id'),
renderHTML: (attrs) =>
attrs.pageId ? { 'data-page-id': attrs.pageId } : {},
},
title: {
default: '',
parseHTML: (el) => el.getAttribute('data-title') ?? el.textContent,
renderHTML: (attrs) => ({ 'data-title': attrs.title }),
},
alias: {
default: null,
parseHTML: (el) => el.getAttribute('data-alias') ?? null,
renderHTML: (attrs) =>
attrs.alias ? { 'data-alias': attrs.alias } : {},
},
};
},
parseHTML() {
return [
{
tag: 'span[data-wikilink]',
},
];
},
renderHTML({ HTMLAttributes, node }) {
const display = node.attrs.alias ?? node.attrs.title ?? '?';
const isBroken = !node.attrs.pageId;
return [
'span',
{
'data-wikilink': 'true',
...HTMLAttributes,
class: isBroken ? 'wikilink wikilink--broken' : 'wikilink',
},
`[[${display}]]`,
];
}, },
addNodeView() { addNodeView() {
return ReactNodeViewRenderer(WikilinkNodeView); return ReactNodeViewRenderer(WikilinkNodeView);
}, },
addCommands() {
return {
insertWikilink:
(attrs: WikilinkAttrs) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs,
});
},
};
},
addInputRules() {
return [
// Input rule fires when the user types [[
// The rule itself doesn't insert a node — it triggers the suggestion popup.
// We use a nodeInputRule with a regex that won't consume anything so the
// Suggestion plugin can take over after detecting the [[ trigger.
// This is intentionally a no-op rule; the real work is in addProseMirrorPlugins.
nodeInputRule({
find: /\[\[Page Title\]\]$/,
type: this.type,
getAttributes: () => ({ pageId: null, title: 'Page Title', alias: null }),
}),
];
},
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [ return [
Suggestion({ Suggestion({
@ -41,42 +151,60 @@ export const WikilinkExtension = WikilinkNode.extend<{
startOfLine: false, startOfLine: false,
pluginKey: new PluginKey('wikilink-suggestion'), pluginKey: new PluginKey('wikilink-suggestion'),
command: ({ editor, range, props }) => { command: ({ editor, range, props }) => {
// Delete the trigger text and insert the node
editor editor
.chain() .chain()
.focus() .focus()
.deleteRange(range) .deleteRange(range)
.insertWikilink({ .insertWikilink({
pageId: props.pageId ?? null, pageId: props.pageId ?? null,
slugId: props.slugId ?? null,
spaceSlug: props.spaceSlug ?? null,
title: props.title, title: props.title,
alias: props.alias ?? null, alias: props.alias ?? null,
}) })
.run(); .run();
}, },
allow: ({ editor }) => { allow: ({ editor, range }) => {
// Only trigger when not inside a code block / code mark
const { $from } = editor.state.selection; const { $from } = editor.state.selection;
const parent = $from.parent;
return ( return (
$from.parent.type.name !== 'codeBlock' && !editor.isActive('code') parent.type.name !== 'codeBlock' &&
!editor.isActive('code')
); );
}, },
...this.options.suggestion, ...this.options.suggestion,
// The render function is provided by the suggestion module and
// rendered via renderWikilinkSuggestion below.
render: renderWikilinkSuggestion, render: renderWikilinkSuggestion,
}), }),
]; ];
}, },
}); });
// ---------------------------------------------------------------------------
// React NodeView component (defined in the same file to keep the module
// self-contained — it does NOT import from the React component world at
// parse time so SSR / unit tests remain safe).
// ---------------------------------------------------------------------------
import React from 'react';
import { NodeViewWrapper } from '@tiptap/react';
import { useNavigate } from 'react-router-dom';
import type { NodeViewProps } from '@tiptap/core';
function WikilinkNodeView({ node }: NodeViewProps) { function WikilinkNodeView({ node }: NodeViewProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { pageId, slugId, spaceSlug, title, alias } = const { pageId, title, alias } = node.attrs as WikilinkAttrs;
node.attrs as WikilinkAttrs;
const display = alias ?? title ?? '?'; const display = alias ?? title ?? '?';
const isBroken = !pageId || !slugId || !spaceSlug; const isBroken = !pageId;
const handleClick = () => { const handleClick = () => {
if (!isBroken && slugId && spaceSlug) { if (pageId) {
navigate(buildPageUrl(spaceSlug, slugId, title)); // Navigate using the same slug-based URL pattern Docmost uses.
// The actual path is <spaceSlug>/page/<slugId> — since we only have
// the UUID here, we navigate to a lookup route that redirects.
// As a fallback, we navigate to a search URL.
navigate(`/page/${pageId}`);
} }
}; };
@ -86,17 +214,13 @@ function WikilinkNodeView({ node }: NodeViewProps) {
data-testid={`wikilink-${pageId ?? 'broken'}`} data-testid={`wikilink-${pageId ?? 'broken'}`}
data-wikilink="true" data-wikilink="true"
data-page-id={pageId ?? undefined} data-page-id={pageId ?? undefined}
data-slug-id={slugId ?? undefined}
data-space-slug={spaceSlug ?? undefined}
data-title={title} data-title={title}
data-alias={alias ?? undefined} data-alias={alias ?? undefined}
className={isBroken ? 'wikilink wikilink--broken' : 'wikilink'} className={isBroken ? 'wikilink wikilink--broken' : 'wikilink'}
onClick={handleClick} onClick={handleClick}
style={{ style={{
cursor: isBroken ? 'not-allowed' : 'pointer', cursor: isBroken ? 'not-allowed' : 'pointer',
color: isBroken color: isBroken ? 'var(--mantine-color-red-6)' : 'var(--mantine-color-blue-6)',
? 'var(--mantine-color-red-6)'
: 'var(--mantine-color-blue-6)',
fontStyle: isBroken ? 'italic' : 'normal', fontStyle: isBroken ? 'italic' : 'normal',
textDecoration: 'underline', textDecoration: 'underline',
userSelect: 'none', userSelect: 'none',

View file

@ -31,7 +31,6 @@ import classes from './wikilink-list.module.css';
export interface WikilinkSuggestionItem { export interface WikilinkSuggestionItem {
pageId: string; pageId: string;
slugId: string | null;
title: string; title: string;
icon: string | null; icon: string | null;
spaceName: string | null; spaceName: string | null;
@ -61,7 +60,6 @@ const WikilinkList = forwardRef<any, WikilinkListProps>((props, ref) => {
const items: WikilinkSuggestionItem[] = (suggestions?.pages ?? []).map((p: any) => ({ const items: WikilinkSuggestionItem[] = (suggestions?.pages ?? []).map((p: any) => ({
pageId: p.id, pageId: p.id,
slugId: p.slugId ?? null,
title: p.title ?? 'Untitled', title: p.title ?? 'Untitled',
icon: p.icon ?? null, icon: p.icon ?? null,
spaceName: p.space?.name ?? null, spaceName: p.space?.name ?? null,

View file

@ -38,8 +38,6 @@ import {
Columns, Columns,
Column, Column,
Status, Status,
WikilinkNode,
DatabaseView,
addUniqueIdsToDoc, addUniqueIdsToDoc,
htmlToMarkdown, htmlToMarkdown,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
@ -103,8 +101,6 @@ export const tiptapExtensions = [
Columns, Columns,
Column, Column,
Status, Status,
WikilinkNode,
DatabaseView,
] as any; ] as any;
export function jsonToHtml(tiptapJson: any) { export function jsonToHtml(tiptapJson: any) {

View file

@ -30,6 +30,4 @@ export * from "./lib/columns";
export * from "./lib/status"; export * from "./lib/status";
export * from "./lib/pdf"; export * from "./lib/pdf";
export * from "./lib/resizable-nodeview"; export * from "./lib/resizable-nodeview";
export * from "./lib/wikilink";
export * from "./lib/database-view";

View file

@ -1,88 +0,0 @@
import { Node, mergeAttributes } from "@tiptap/core";
export interface DatabaseViewAttrs {
tableId: string;
viewId: string;
viewType: string;
bridgeUrl?: string | null;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
databaseView: {
insertDatabaseView: (attrs: DatabaseViewAttrs) => ReturnType;
};
}
}
/**
* Shared DatabaseView node (schema only, no NodeView).
*
* Registered on the Hocuspocus server so embedded Baserow views survive
* collab saves. The client extends this node to attach the React renderer.
*/
export const DatabaseView = Node.create({
name: "database-view",
group: "block",
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return {
tableId: {
default: "",
parseHTML: (el: HTMLElement) => el.getAttribute("data-table-id") ?? "",
renderHTML: (attrs) => ({ "data-table-id": attrs.tableId }),
},
viewId: {
default: "",
parseHTML: (el: HTMLElement) => el.getAttribute("data-view-id") ?? "",
renderHTML: (attrs) => ({ "data-view-id": attrs.viewId }),
},
viewType: {
default: "grid",
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-view-type") ?? "grid",
renderHTML: (attrs) => ({ "data-view-type": attrs.viewType }),
},
bridgeUrl: {
default: null,
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-bridge-url") ?? null,
renderHTML: (attrs) =>
attrs.bridgeUrl ? { "data-bridge-url": attrs.bridgeUrl } : {},
},
};
},
parseHTML() {
return [{ tag: "div[data-node-type=database-view]" }];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, { "data-node-type": "database-view" }),
];
},
addCommands() {
return {
insertDatabaseView:
(attrs: DatabaseViewAttrs) =>
({ commands }) =>
commands.insertContent({
type: this.name,
attrs: {
tableId: attrs.tableId,
viewId: attrs.viewId,
viewType: attrs.viewType,
bridgeUrl: attrs.bridgeUrl ?? null,
},
}),
};
},
});
export default DatabaseView;

View file

@ -1,99 +0,0 @@
import { Node } from "@tiptap/core";
export interface WikilinkAttrs {
pageId: string | null;
slugId?: string | null;
spaceSlug?: string | null;
title: string;
alias: string | null;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
wikilink: {
insertWikilink: (attrs: WikilinkAttrs) => ReturnType;
};
}
}
/**
* Shared Wikilink node (schema only, no NodeView).
*
* Lives in @docmost/editor-ext so both the Hocuspocus server and the React
* client share the exact same schema. The client extends this node to add
* the React NodeView, input rule, and suggestion plugin.
*
* Without registering this node on the server, Hocuspocus' jsonToNode strips
* wikilink nodes on save (unknown node type).
*/
export const WikilinkNode = Node.create({
name: "wikilink",
group: "inline",
inline: true,
atom: true,
selectable: true,
addAttributes() {
return {
pageId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-page-id"),
renderHTML: (attrs) =>
attrs.pageId ? { "data-page-id": attrs.pageId } : {},
},
slugId: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-slug-id"),
renderHTML: (attrs) =>
attrs.slugId ? { "data-slug-id": attrs.slugId } : {},
},
spaceSlug: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-space-slug"),
renderHTML: (attrs) =>
attrs.spaceSlug ? { "data-space-slug": attrs.spaceSlug } : {},
},
title: {
default: "",
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-title") ?? el.textContent ?? "",
renderHTML: (attrs) => ({ "data-title": attrs.title }),
},
alias: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-alias"),
renderHTML: (attrs) =>
attrs.alias ? { "data-alias": attrs.alias } : {},
},
};
},
parseHTML() {
return [{ tag: "span[data-wikilink]" }];
},
renderHTML({ HTMLAttributes, node }) {
const display = node.attrs.alias ?? node.attrs.title ?? "?";
const isBroken = !node.attrs.pageId;
return [
"span",
{
"data-wikilink": "true",
...HTMLAttributes,
class: isBroken ? "wikilink wikilink--broken" : "wikilink",
},
`[[${display}]]`,
];
},
addCommands() {
return {
insertWikilink:
(attrs: WikilinkAttrs) =>
({ commands }) =>
commands.insertContent({ type: this.name, attrs }),
};
},
});
export default WikilinkNode;