Compare commits
No commits in common. "43a70929ec11c5bc3b0c921d7d7d98154516779b" and "dbd79cc17c448bb6bb77d8754776011191982575" have entirely different histories.
43a70929ec
...
dbd79cc17c
14 changed files with 287 additions and 523 deletions
|
|
@ -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"));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
Loading…
Add table
Reference in a new issue