chore(sync): sync updates from docmost/docmost

This commit is contained in:
Forgejo Actions 2026-10-25 04:00:30 +00:00
commit 678615d162
70 changed files with 2032 additions and 603 deletions

View file

@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.96.0",
"version": "0.97.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@ -66,7 +66,7 @@
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"yet-another-react-lightbox": "^3.32.2",
"zod": "4.3.6"
"zod": "4.6.7"
},
"devDependencies": {
"@eslint/js": "9.28.0",
@ -94,7 +94,7 @@
"prettier": "3.8.1",
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "8.0.16",
"vitest": "4.1.11"
"vite": "8.3.4",
"vitest": "4.1.12"
}
}

View file

@ -1573,5 +1573,6 @@
"Move right": "Nach rechts verschieben",
"Delete tab": "Tab löschen",
"Tab label": "Tab-Beschriftung",
"Tab actions for {{name}}": "Tab-Aktionen für {{name}}"
"Tab actions for {{name}}": "Tab-Aktionen für {{name}}",
"Inline base": "Inline-Basis"
}

View file

@ -1573,5 +1573,6 @@
"Move right": "Move right",
"Delete tab": "Delete tab",
"Tab label": "Tab label",
"Tab actions for {{name}}": "Tab actions for {{name}}"
"Tab actions for {{name}}": "Tab actions for {{name}}",
"Inline base": "Inline base"
}

View file

@ -1573,5 +1573,6 @@
"Move right": "Mover a la derecha",
"Delete tab": "Eliminar pestaña",
"Tab label": "Etiqueta de la pestaña",
"Tab actions for {{name}}": "Acciones de pestaña para {{name}}"
"Tab actions for {{name}}": "Acciones de pestaña para {{name}}",
"Inline base": "Base integrada"
}

View file

@ -1573,5 +1573,6 @@
"Move right": "Déplacer vers la droite",
"Delete tab": "Supprimer l’onglet",
"Tab label": "Libellé de l’onglet",
"Tab actions for {{name}}": "Actions de l’onglet pour {{name}}"
"Tab actions for {{name}}": "Actions de l’onglet pour {{name}}",
"Inline base": "Base en ligne"
}

View file

@ -1573,5 +1573,6 @@
"Move right": "Sposta a destra",
"Delete tab": "Elimina scheda",
"Tab label": "Etichetta scheda",
"Tab actions for {{name}}": "Azioni scheda per {{name}}"
"Tab actions for {{name}}": "Azioni scheda per {{name}}",
"Inline base": "Base inline"
}

View file

@ -1573,5 +1573,6 @@
"Move right": "右に移動",
"Delete tab": "タブを削除",
"Tab label": "タブラベル",
"Tab actions for {{name}}": "{{name}} のタブアクション"
"Tab actions for {{name}}": "{{name}} のタブアクション",
"Inline base": "インラインベース"
}

View file

@ -1573,5 +1573,6 @@
"Move right": "오른쪽으로 이동",
"Delete tab": "탭 삭제",
"Tab label": "탭 레이블",
"Tab actions for {{name}}": "{{name}} 탭 작업"
"Tab actions for {{name}}": "{{name}} 탭 작업",
"Inline base": "인라인 베이스"
}

View file

@ -1573,5 +1573,6 @@
"Move right": "Naar rechts verplaatsen",
"Delete tab": "Tabblad verwijderen",
"Tab label": "Tabbladlabel",
"Tab actions for {{name}}": "Tabbladacties voor {{name}}"
"Tab actions for {{name}}": "Tabbladacties voor {{name}}",
"Inline base": "Inline basis"
}

View file

@ -1573,5 +1573,6 @@
"Move right": "Mover para a direita",
"Delete tab": "Excluir aba",
"Tab label": "Rótulo da aba",
"Tab actions for {{name}}": "Ações da aba para {{name}}"
"Tab actions for {{name}}": "Ações da aba para {{name}}",
"Inline base": "Base embutida"
}

View file

@ -1573,5 +1573,6 @@
"Move right": "Переместить вправо",
"Delete tab": "Удалить вкладку",
"Tab label": "Название вкладки",
"Tab actions for {{name}}": "Действия с вкладкой для {{name}}"
"Tab actions for {{name}}": "Действия с вкладкой для {{name}}",
"Inline base": "Базовый встроенный элемент"
}

View file

@ -1573,5 +1573,6 @@
"Move right": "Перемістити праворуч",
"Delete tab": "Видалити вкладку",
"Tab label": "Назва вкладки",
"Tab actions for {{name}}": "Дії вкладки для {{name}}"
"Tab actions for {{name}}": "Дії вкладки для {{name}}",
"Inline base": "Вбудована основа"
}

View file

@ -1573,5 +1573,6 @@
"Move right": "右移",
"Delete tab": "删除标签页",
"Tab label": "标签页标签",
"Tab actions for {{name}}": "{{name}}的标签页操作"
"Tab actions for {{name}}": "{{name}}的标签页操作",
"Inline base": "内联基础"
}

View file

@ -15,13 +15,12 @@ type CellEmailProps = {
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft || null;
export function CellEmail({ value, property, rowId, isEditing, onCommit, onCancel }: CellEmailProps) {
export function CellEmail({ value, property, rowId, isEditing, onCommit }: CellEmailProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,

View file

@ -0,0 +1,107 @@
import { MantineProvider } from "@mantine/core";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IBaseProperty } from "@/ee/base/types/base.types";
import { CellLongText } from "./cell-long-text";
vi.mock("@/main.tsx", async () => {
const { QueryClient } = await import("@tanstack/react-query");
return { queryClient: new QueryClient() };
});
const property = {
id: "p1",
pageId: "base-1",
name: "Long text",
type: "text",
position: "a",
typeOptions: {},
isPrimary: false,
} as unknown as IBaseProperty;
function setMediaMatch() {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
beforeEach(() => {
setMediaMatch();
// Textarea autosize listens for font loads, which jsdom doesn't implement.
Object.defineProperty(document, "fonts", {
configurable: true,
value: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
});
});
afterEach(cleanup);
describe("CellLongText", () => {
it("saves what was typed when Esc closes the editor", () => {
const onCommit = vi.fn();
const onCancel = vi.fn();
render(
<CellLongText
value=""
property={property}
rowId="r1"
isEditing
onCommit={onCommit}
onValueChange={vi.fn()}
onCancel={onCancel}
/>,
{
wrapper: ({ children }: { children: ReactNode }) => (
<MantineProvider env="test">{children}</MantineProvider>
),
},
);
const textarea = screen.getByRole("textbox");
fireEvent.change(textarea, { target: { value: "what is love" } });
fireEvent.keyDown(textarea, { key: "Escape" });
expect(onCommit).toHaveBeenCalledWith("what is love");
expect(onCancel).not.toHaveBeenCalled();
});
it("autosaves after a 1 s typing pause", () => {
vi.useFakeTimers();
const onValueChange = vi.fn();
render(
<CellLongText
value=""
property={property}
rowId="r1"
isEditing
onCommit={vi.fn()}
onValueChange={onValueChange}
onCancel={vi.fn()}
/>,
{
wrapper: ({ children }: { children: ReactNode }) => (
<MantineProvider env="test">{children}</MantineProvider>
),
},
);
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "what is love" },
});
act(() => vi.advanceTimersByTime(1000));
expect(onValueChange).toHaveBeenCalledWith("what is love");
vi.useRealTimers();
});
});

View file

@ -4,6 +4,7 @@ import { useDebouncedCallback } from "@mantine/hooks";
import { IBaseProperty } from "@/ee/base/types/base.types";
import { formatLongTextPreview } from "@/ee/base/formatters/cell-formatters";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { AUTOSAVE_DELAY_MS } from "@/ee/base/constants/autosave";
type CellLongTextProps = {
value: unknown;
@ -27,11 +28,9 @@ export function CellLongText({
isEditing,
onCommit,
onValueChange,
onCancel,
onTabNavigate,
}: CellLongTextProps) {
const [draft, setDraft] = useState(() => toText(value));
const cancelledRef = useRef(false);
const committedRef = useRef(false);
const wasEditingRef = useRef(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -40,7 +39,6 @@ export function CellLongText({
// value changes mid-edit so the user's typing is not clobbered.
useEffect(() => {
if (isEditing && !wasEditingRef.current) {
cancelledRef.current = false;
committedRef.current = false;
setDraft(toText(value));
requestAnimationFrame(() => {
@ -53,11 +51,11 @@ export function CellLongText({
wasEditingRef.current = isEditing;
}, [isEditing, value]);
// Autosave after a typing pause; commit/cancel clear the pending fire so
// a closed editor can never write a stale or discarded draft.
// Autosave after a typing pause; commit clears the pending fire so a
// closed editor can never write a stale draft.
const debouncedAutosave = useDebouncedCallback(() => {
onValueChange(normalize(draft));
}, 10_000);
}, AUTOSAVE_DELAY_MS);
const commit = () => {
if (committedRef.current) return;
@ -65,11 +63,6 @@ export function CellLongText({
debouncedAutosave.cancel();
onCommit(normalize(draft));
};
const cancel = () => {
cancelledRef.current = true;
debouncedAutosave.cancel();
onCancel();
};
const preview = formatLongTextPreview(toText(value));
@ -78,11 +71,6 @@ export function CellLongText({
opened={isEditing}
onChange={(opened) => {
if (opened) return;
// Programmatic close after cancel must not re-commit.
if (cancelledRef.current) {
cancelledRef.current = false;
return;
}
commit();
}}
position="bottom-start"
@ -135,7 +123,7 @@ export function CellLongText({
onTabNavigate?.(e.shiftKey);
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
commit();
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
commit();

View file

@ -95,7 +95,6 @@ export function CellNumber({
rowId,
isEditing,
onCommit,
onCancel,
}: CellNumberProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
@ -103,7 +102,6 @@ export function CellNumber({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse: parseNumberDraft,
rowId,

View file

@ -0,0 +1,45 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { IBaseProperty } from "@/ee/base/types/base.types";
import { CellText } from "./cell-text";
vi.mock("@/main.tsx", async () => {
const { QueryClient } = await import("@tanstack/react-query");
return { queryClient: new QueryClient() };
});
const property = {
id: "p1",
pageId: "base-1",
name: "Text",
type: "text",
position: "a",
typeOptions: {},
isPrimary: false,
} as unknown as IBaseProperty;
afterEach(cleanup);
describe("CellText", () => {
it("saves what was typed when Esc ends the edit", () => {
const onCommit = vi.fn();
const onCancel = vi.fn();
render(
<CellText
value=""
property={property}
rowId="r1"
isEditing
onCommit={onCommit}
onCancel={onCancel}
/>,
);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "hello" } });
fireEvent.keyDown(input, { key: "Escape" });
expect(onCommit).toHaveBeenCalledWith("hello");
expect(onCancel).not.toHaveBeenCalled();
});
});

View file

@ -16,13 +16,12 @@ type CellTextProps = {
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft;
export function CellText({ value, property, rowId, isEditing, onCommit, onCancel }: CellTextProps) {
export function CellText({ value, property, rowId, isEditing, onCommit }: CellTextProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,

View file

@ -16,13 +16,12 @@ type CellUrlProps = {
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft || null;
export function CellUrl({ value, property, rowId, isEditing, onCommit, onCancel }: CellUrlProps) {
export function CellUrl({ value, property, rowId, isEditing, onCommit }: CellUrlProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,

View file

@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { Textarea } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
import { AUTOSAVE_DELAY_MS } from "@/ee/base/constants/autosave";
const toText = (value: unknown) => (typeof value === "string" ? value : "");
const normalize = (s: string) => {
@ -19,23 +21,21 @@ export function FieldLongText({
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
// Esc sets this; blur() then runs commit synchronously with the stale
// draft, so the revert must be decided here, not via setDraft.
const cancelRef = useRef(false);
useEffect(() => {
if (!focused) setDraft(text);
}, [text, focused]);
const save = () => {
if (normalize(draft) !== normalize(text)) onChange(normalize(draft));
};
const autosave = useDebouncedCallback(save, AUTOSAVE_DELAY_MS);
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
return;
}
if (normalize(draft) !== normalize(text)) onChange(normalize(draft));
autosave.cancel();
save();
};
if (readOnly) {
@ -61,11 +61,13 @@ export function FieldLongText({
setFocused(true);
onEditingChange?.(true);
}}
onChange={(e) => setDraft(e.currentTarget.value)}
onChange={(e) => {
setDraft(e.currentTarget.value);
autosave();
}}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { NumberTypeOptions } from "@/ee/base/types/base.types";
import {
formatNumber,
@ -22,9 +22,6 @@ export function FieldNumber({
const numValue = typeof value === "number" ? value : null;
const [draft, setDraft] = useState(toDraft(value));
const [focused, setFocused] = useState(false);
// Esc sets this; blur() then runs commit synchronously with the stale
// draft, so the revert must be decided here, not via setDraft.
const cancelRef = useRef(false);
useEffect(() => {
if (!focused) setDraft(toDraft(value));
@ -43,11 +40,6 @@ export function FieldNumber({
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(toDraft(value));
return;
}
if (parseNumberDraft(draft) !== numValue) onChange(parseNumberDraft(draft));
};
@ -86,7 +78,6 @@ export function FieldNumber({
e.preventDefault();
e.currentTarget.blur();
} else if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
}
}}

View file

@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useDebouncedCallback } from "@mantine/hooks";
import { IconExternalLink, IconMail } from "@tabler/icons-react";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
import { AUTOSAVE_DELAY_MS } from "@/ee/base/constants/autosave";
const toText = (value: unknown) => (typeof value === "string" ? value : "");
@ -15,24 +17,22 @@ export function FieldText({
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
// Esc sets this; blur() then runs commit synchronously with the stale
// draft, so the revert must be decided here, not via setDraft.
const cancelRef = useRef(false);
// Track remote/navigation updates while not typing.
useEffect(() => {
if (!focused) setDraft(text);
}, [text, focused]);
const save = () => {
if (draft !== text) onChange(draft);
};
const autosave = useDebouncedCallback(save, AUTOSAVE_DELAY_MS);
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
return;
}
if (draft !== text) onChange(draft);
autosave.cancel();
save();
};
if (readOnly) {
@ -65,14 +65,16 @@ export function FieldText({
setFocused(true);
onEditingChange?.(true);
}}
onChange={(e) => setDraft(e.currentTarget.value)}
onChange={(e) => {
setDraft(e.currentTarget.value);
autosave();
}}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
} else if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
}
}}

View file

@ -0,0 +1,185 @@
import { MantineProvider } from "@mantine/core";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ComponentType } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { RowDetailTitle } from "./row-detail-title";
import { FieldProps } from "./fields/detail-field";
import { FieldText } from "./fields/field-text";
import { FieldNumber } from "./fields/field-number";
import { FieldLongText } from "./fields/field-long-text";
vi.mock("@/main.tsx", async () => {
const { QueryClient } = await import("@tanstack/react-query");
return { queryClient: new QueryClient() };
});
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<object>()),
useTranslation: () => ({ t: (value: string) => value }),
}));
const property = (type: IBaseProperty["type"]) =>
({
id: "p1",
pageId: "base-1",
name: "Field",
type,
position: "a",
typeOptions: {},
isPrimary: false,
}) as IBaseProperty;
function setMediaMatch() {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
// Real focus, so the blur that Esc triggers reaches onBlur as in the browser.
function typeThenEscape(value: string) {
const input = screen.getByRole("textbox");
act(() => input.focus());
fireEvent.change(input, { target: { value } });
fireEvent.keyDown(input, { key: "Escape" });
return input;
}
beforeEach(() => {
setMediaMatch();
// Textarea autosize listens for font loads, which jsdom doesn't implement.
Object.defineProperty(document, "fonts", {
configurable: true,
value: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
});
});
afterEach(cleanup);
describe("Esc in the row detail modal", () => {
it("saves a typed title before leaving the field", () => {
const onCommit = vi.fn();
render(
<RowDetailTitle
row={{ id: "r1", cells: {} } as IBaseRow}
primaryProperty={{ ...property("text"), isPrimary: true }}
canEdit
onCommit={onCommit}
/>,
);
const input = typeThenEscape("dddddd");
expect(onCommit).toHaveBeenCalledWith("dddddd");
expect(document.activeElement).not.toBe(input);
});
it.each([
["text", FieldText, "text", "hello", "hello"],
["number", FieldNumber, "number", "42", 42],
["long text", FieldLongText, "text", "notes", "notes"],
] as [string, ComponentType<FieldProps>, IBaseProperty["type"], string, unknown][])(
"saves a typed %s field before leaving it",
(_, Field, type, typed, saved) => {
const onChange = vi.fn();
render(
<Field
property={property(type)}
value={null}
rowId="r1"
readOnly={false}
onChange={onChange}
/>,
{ wrapper: MantineProvider },
);
const input = typeThenEscape(typed);
expect(onChange).toHaveBeenCalledWith(saved);
expect(document.activeElement).not.toBe(input);
},
);
});
describe("Typing in the row detail modal", () => {
const titleRow = (title: string) =>
({ id: "r1", cells: { p1: title } }) as unknown as IBaseRow;
const primary = { ...property("text"), isPrimary: true };
function type(value: string) {
const input = screen.getByRole("textbox");
act(() => input.focus());
fireEvent.change(input, { target: { value } });
return input;
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("saves a typed title after a 1 s pause, without leaving the field", () => {
const onCommit = vi.fn();
render(
<RowDetailTitle
row={titleRow("")}
primaryProperty={primary}
canEdit
onCommit={onCommit}
/>,
);
const input = type("dddddd");
act(() => vi.advanceTimersByTime(999));
expect(onCommit).not.toHaveBeenCalled();
act(() => vi.advanceTimersByTime(1));
expect(onCommit).toHaveBeenCalledWith("dddddd");
expect(document.activeElement).toBe(input);
});
it("keeps a typed title when the row's title changes elsewhere", () => {
const props = { primaryProperty: primary, canEdit: true, onCommit: vi.fn() };
const { rerender } = render(
<RowDetailTitle row={titleRow("Old")} {...props} />,
);
const input = type("My draft");
rerender(<RowDetailTitle row={titleRow("Remote")} {...props} />);
expect(input).toHaveProperty("value", "My draft");
});
it.each([
["text", FieldText, "hello"],
["long text", FieldLongText, "notes"],
] as [string, ComponentType<FieldProps>, string][])(
"saves a typed %s field after a 1 s pause",
(_, Field, typed) => {
const onChange = vi.fn();
render(
<Field
property={property("text")}
value={null}
rowId="r1"
readOnly={false}
onChange={onChange}
/>,
{ wrapper: MantineProvider },
);
const input = type(typed);
act(() => vi.advanceTimersByTime(1000));
expect(onChange).toHaveBeenCalledWith(typed);
expect(document.activeElement).toBe(input);
},
);
});

View file

@ -0,0 +1,116 @@
import { MantineProvider } from "@mantine/core";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IBase, IBaseRow } from "@/ee/base/types/base.types";
import { RowDetailModal } from "./row-detail-modal";
const mocks = vi.hoisted(() => ({ updateRow: vi.fn() }));
vi.mock("@/main.tsx", async () => {
const { QueryClient } = await import("@tanstack/react-query");
return { queryClient: new QueryClient() };
});
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<object>()),
useTranslation: () => ({ t: (value: string) => value }),
}));
vi.mock("@/ee/base/queries/base-row-query", () => ({
useBaseRowQuery: () => ({ data: undefined, isError: false }),
useUpdateRowMutation: () => ({ mutate: mocks.updateRow, isPending: false }),
useDeleteRowMutation: () => ({ mutate: vi.fn() }),
}));
vi.mock("@/ee/base/context/base-editable", () => ({
useBaseEditable: () => true,
}));
vi.mock("@/ee/base/components/property/create-property-popover", () => ({
CreatePropertyPopover: () => null,
}));
const base = {
id: "base-1",
properties: [
{
id: "p1",
pageId: "base-1",
name: "Name",
type: "text",
position: "a",
typeOptions: {},
isPrimary: true,
},
],
} as unknown as IBase;
const row = {
id: "r1",
pageId: "base-1",
cells: {},
position: "a",
} as unknown as IBaseRow;
function setMediaMatch() {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
function renderModal(onClose: () => void) {
render(
<RowDetailModal
base={base}
rows={[row]}
openRowId="r1"
onClose={onClose}
onNavigate={vi.fn()}
/>,
{
wrapper: ({ children }: { children: ReactNode }) => (
<MantineProvider env="test">{children}</MantineProvider>
),
},
);
}
beforeEach(() => {
setMediaMatch();
mocks.updateRow.mockReset();
});
afterEach(cleanup);
describe("RowDetailModal Esc", () => {
it("saves a typed title, then closes, in one press", () => {
const onClose = vi.fn();
renderModal(onClose);
const title = screen.getByRole("textbox", { name: "Name" });
act(() => title.focus());
fireEvent.change(title, { target: { value: "dddddd" } });
fireEvent.keyDown(title, { key: "Escape" });
expect(mocks.updateRow).toHaveBeenCalledWith({
rowId: "r1",
pageId: "base-1",
cells: { p1: "dddddd" },
});
expect(onClose).toHaveBeenCalledTimes(1);
expect(mocks.updateRow.mock.invocationCallOrder[0]).toBeLessThan(
onClose.mock.invocationCallOrder[0],
);
});
});

View file

@ -168,10 +168,11 @@ export function RowDetailModal({
// Mantine's closeOnEscape runs a capture-phase window listener that fires
// before inner popovers and inputs see the key, so we manage Esc ourselves
// and yield to: nested dialogs (delete confirm), open popovers
// ([data-position]) and editable elements. Arrows step records under the
// same yield rules. Mantine puts role="dialog" and our content class on
// the same element, which distinguishes this modal from nested ones.
// and yield to nested dialogs (delete confirm) and open popovers
// ([data-position]). Esc in an inline field blurs it first, which saves the
// edit, then closes; arrows stay with the field. Mantine puts role="dialog"
// and our content class on the same element, which distinguishes this
// modal from nested ones.
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
const isEscape = event.key === "Escape";
@ -183,11 +184,10 @@ export function RowDetailModal({
if (dialog && !dialog.classList.contains(classes.modalContent)) {
return;
}
if (
target.closest("[data-position]") ||
target.matches("input, textarea, select, [contenteditable='true']")
) {
return;
if (target.closest("[data-position]")) return;
if (target.matches("input, textarea, select, [contenteditable='true']")) {
if (!isEscape) return;
target.blur();
}
}
if (isEscape) {
@ -383,7 +383,7 @@ export function RowDetailModal({
<span className={classes.kbdGroup}>
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to reset")}</span>
<span>{t("to close")}</span>
</span>
</>
) : (

View file

@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { useDebouncedCallback } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { timeAgo } from "@/lib/time.ts";
import { AUTOSAVE_DELAY_MS } from "@/ee/base/constants/autosave";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
type RowDetailTitleProps = {
@ -24,21 +26,23 @@ export function RowDetailTitle({
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
: "";
const [value, setValue] = useState(initial);
const cancelRef = useRef(false);
const focusedRef = useRef(false);
const commit = () => {
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setValue(initial);
return;
}
const save = () => {
if (value !== initial) onCommit(value);
};
const autosave = useDebouncedCallback(save, AUTOSAVE_DELAY_MS);
// Re-sync when the row changes underneath us (navigation or remote edit).
const commit = () => {
focusedRef.current = false;
onEditingChange?.(false);
autosave.cancel();
save();
};
// Re-sync when the row changes underneath us (navigation or remote edit), but not mid-typing.
useEffect(() => {
setValue(initial);
if (!focusedRef.current) setValue(initial);
}, [initial]);
const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : "";
@ -55,13 +59,16 @@ export function RowDetailTitle({
value={value}
maxLength={1000}
onFocus={() => {
focusedRef.current = true;
onEditingChange?.(true);
}}
onChange={(e) => setValue(e.currentTarget.value)}
onChange={(e) => {
setValue(e.currentTarget.value);
autosave();
}}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
} else if (e.key === "Enter") {
e.preventDefault();

View file

@ -0,0 +1,2 @@
// Typing pause after which a text edit saves itself.
export const AUTOSAVE_DELAY_MS = 1000;

View file

@ -6,7 +6,6 @@ export type UseEditableTextCellParams = {
value: unknown;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
/** value -> the draft string shown in the input when editing begins */
toDraft: (value: unknown) => string;
/** draft string -> the value passed to onCommit */
@ -27,7 +26,6 @@ export function useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,
@ -85,11 +83,10 @@ export function useEditableTextCell({
commitOnce(parse(draft));
} else if (e.key === "Escape") {
e.preventDefault();
committedRef.current = true;
onCancel();
commitOnce(parse(draft));
}
},
[draft, parse, commitOnce, onCancel],
[draft, parse, commitOnce],
);
const handleBlur = useCallback(() => {

View file

@ -0,0 +1,56 @@
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useGridKeyboardNav } from "./use-grid-keyboard-nav";
type Options = Parameters<typeof useGridKeyboardNav>[0];
afterEach(() => {
document.body.innerHTML = "";
});
describe("useGridKeyboardNav while editing", () => {
it("blurs the cell input, which saves it, before leaving edit mode on Esc", () => {
const container = document.createElement("div");
const input = document.createElement("input");
container.appendChild(input);
document.body.appendChild(container);
const order: string[] = [];
input.addEventListener("blur", () => order.push("blur"));
const setEditingCell = vi.fn(() => {
order.push("stop editing");
});
renderHook(() =>
useGridKeyboardNav({
table: {
getVisibleLeafColumns: () => [],
getRowModel: () => ({ rows: [] }),
},
properties: [],
containerRef: { current: container },
focusedCell: null,
setFocusedCell: vi.fn(),
editingCell: { rowId: "r1", propertyId: "p1" },
setEditingCell,
openEditor: vi.fn(),
clearCell: vi.fn(),
beginTypeToEdit: vi.fn(),
scrollCellIntoView: vi.fn(),
selectionCount: 0,
clearSelection: vi.fn(),
deleteSelected: vi.fn(),
toggleRowSelection: vi.fn(),
expandRow: vi.fn(),
addRow: vi.fn(),
} as unknown as Options),
);
input.focus();
input.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
);
expect(order).toEqual(["blur", "stop editing"]);
});
});

View file

@ -171,6 +171,8 @@ export function useGridKeyboardNav({
}
case "Escape": {
e.preventDefault();
// Blurring saves the draft, as on Enter.
(document.activeElement as HTMLElement | null)?.blur();
setEditingCell(null);
setFocusedCell(editingCell);
break;

View file

@ -1,11 +1,6 @@
.row {
position: relative;
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border-radius: var(--mantine-radius-sm);
width: 100%;
@mixin hover {
background-color: light-dark(
@ -15,6 +10,15 @@
}
}
.pickButton {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border-radius: inherit;
width: 100%;
}
.icon {
width: 22px;
height: 22px;

View file

@ -196,41 +196,39 @@ export default function TemplatePickerModal({
</div>
) : (
filtered.map((tpl) => (
<UnstyledButton
key={tpl.id}
className={classes.row}
onClick={() => handlePick(tpl)}
>
<div className={classes.icon}>
{tpl.icon ? (
<span>{tpl.icon}</span>
) : (
<IconFileText
size={16}
color="var(--mantine-color-gray-6)"
/>
)}
</div>
<div className={classes.title}>{tpl.title}</div>
<div className={classes.scope}>
{tpl.spaceId
? spaceNamesById.get(tpl.spaceId) ?? t("Space")
: t("Global")}
</div>
<div key={tpl.id} className={classes.row}>
<UnstyledButton
className={classes.pickButton}
onClick={() => handlePick(tpl)}
>
<div className={classes.icon}>
{tpl.icon ? (
<span>{tpl.icon}</span>
) : (
<IconFileText
size={16}
color="var(--mantine-color-gray-6)"
/>
)}
</div>
<div className={classes.title}>{tpl.title}</div>
<div className={classes.scope}>
{tpl.spaceId
? spaceNamesById.get(tpl.spaceId) ?? t("Space")
: t("Global")}
</div>
</UnstyledButton>
<Button
size="compact-xs"
variant="filled"
className={classes.useButton}
loading={useTemplateMutation.isPending}
disabled={useTemplateMutation.isPending}
onClick={(e) => {
e.stopPropagation();
handleQuickUse(tpl);
}}
onClick={() => handleQuickUse(tpl)}
>
{t("Use")}
</Button>
</UnstyledButton>
</div>
))
)}
</ScrollArea>

View file

@ -8,8 +8,12 @@ import { useBaseQuery } from "@/ee/base/queries/base-query";
import { pinOffsetWatcher } from "@docmost/editor-ext";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { IconDots, IconTable, IconX } from "@tabler/icons-react";
import { IconDots, IconLink, IconTable, IconX } from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import { usePageQuery } from "@/features/page/queries/page-query";
import { useClipboard } from "@/hooks/use-clipboard";
import { getAppUrl } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "./base-embed.module.css";
const SIDE_GUTTER = 8;
@ -63,6 +67,14 @@ export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) {
pendingKey ? "" : pageId ?? "",
);
const { data: page } = usePageQuery({ pageId: pageId ?? undefined });
const clipboard = useClipboard({ timeout: 500 });
const handleCopyLink = () => {
clipboard.copy(
getAppUrl() + buildPageUrl(page.space?.slug, page.slugId, page.title),
);
notifications.show({ message: t("Link copied") });
};
useEffect(() => {
const wrapper = wrapperRef.current;
@ -98,7 +110,8 @@ export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) {
// mounts) is reserved only for the skeleton/loading/table states.
const isCompact = !pendingKey && (!pageId || isError);
const showControls = editor.isEditable && !pendingKey;
// Also on the skeleton, so a placeholder stuck pending can still be removed.
const showControls = editor.isEditable;
let content: React.ReactNode;
if (pendingKey) {
@ -159,6 +172,13 @@ export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) {
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLink size={14} />}
disabled={!page}
onClick={handleCopyLink}
>
{t("Copy link")}
</Menu.Item>
<Menu.Item
leftSection={<IconX size={14} />}
onClick={() => deleteNode()}

View file

@ -0,0 +1,125 @@
import { MantineProvider } from "@mantine/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BasePasteMenu } from "./base-paste-menu";
const mocks = vi.hoisted(() => ({
pasteState: null as { pos: number } | null,
hasBases: true,
convert: vi.fn(),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (value: string) => value }),
}));
vi.mock("@tiptap/react", () => ({
posToDOMRect: vi.fn(),
useEditorState: ({ editor, selector }: Record<string, any>) =>
selector({ editor }),
}));
vi.mock("@tiptap/react/menus", () => ({
BubbleMenu: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@/features/editor/extensions/base-paste-menu", () => ({
basePasteMenuKey: { getState: () => mocks.pasteState },
}));
vi.mock("@/ee/hooks/use-feature", () => ({
useHasFeature: () => mocks.hasBases,
}));
vi.mock("./convert-mention-to-base-embed", () => ({
convertMentionToBaseEmbed: mocks.convert,
}));
function setMediaMatch() {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
function renderMenu() {
const editor = {
state: {
doc: { nodeAt: () => ({ type: { name: "mention" }, nodeSize: 1 }) },
tr: { setMeta: vi.fn() },
},
view: { dispatch: vi.fn(), nodeDOM: vi.fn(), hasFocus: () => true },
commands: { focus: vi.fn() },
isDestroyed: false,
on: vi.fn(),
off: vi.fn(),
};
render(
<MantineProvider>
<BasePasteMenu
editor={
editor as unknown as ComponentProps<typeof BasePasteMenu>["editor"]
}
/>
</MantineProvider>,
);
return editor;
}
describe("BasePasteMenu", () => {
beforeEach(() => {
setMediaMatch();
mocks.pasteState = { pos: 3 };
mocks.hasBases = true;
mocks.convert.mockReset();
});
afterEach(cleanup);
it("embeds the pasted base when its option is clicked", () => {
const editor = renderMenu();
fireEvent.click(screen.getByRole("option", { name: "Inline base" }));
expect(mocks.convert).toHaveBeenCalledWith(editor, 3);
});
it("leaves Enter to the editor until the option is highlighted", () => {
const editor = renderMenu();
expect(fireEvent.keyDown(window, { key: "Enter" })).toBe(true);
expect(mocks.convert).not.toHaveBeenCalled();
fireEvent.keyDown(window, { key: "ArrowDown" });
expect(fireEvent.keyDown(window, { key: "Enter" })).toBe(false);
expect(mocks.convert).toHaveBeenCalledWith(editor, 3);
});
it("closes on Escape and keeps the mention", () => {
const editor = renderMenu();
fireEvent.keyDown(window, { key: "Escape" });
expect(editor.view.dispatch).toHaveBeenCalled();
expect(mocks.convert).not.toHaveBeenCalled();
});
it("stays hidden without the Bases feature", () => {
mocks.hasBases = false;
renderMenu();
expect(screen.queryByRole("option")).toBeNull();
});
});

View file

@ -0,0 +1,186 @@
import { Button, Paper, Text, VisuallyHidden } from "@mantine/core";
import { posToDOMRect, useEditorState } from "@tiptap/react";
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { basePasteMenuKey } from "@/features/editor/extensions/base-paste-menu";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { convertMentionToBaseEmbed } from "./convert-mention-to-base-embed";
import classes from "../integration-link/integration-paste-menu.module.css";
function getPastedBasePos(editor: EditorMenuProps["editor"]): number | null {
const pasteState = basePasteMenuKey.getState(editor.state);
if (!pasteState) return null;
const node = editor.state.doc.nodeAt(pasteState.pos);
return node?.type.name === "mention" ? pasteState.pos : null;
}
export function BasePasteMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const hasBases = useHasFeature(Feature.BASES);
const [highlighted, setHighlighted] = useState(false);
const optionId = useId();
const promptRef = useRef<HTMLDivElement>(null);
const pastedPos = useEditorState({
editor,
selector: ({ editor: currentEditor }) =>
currentEditor ? getPastedBasePos(currentEditor) : null,
});
const isOpen = hasBases && pastedPos !== null;
const shouldShow = useCallback(
() => hasBases && getPastedBasePos(editor) !== null,
[editor, hasBases],
);
const getReferencedVirtualElement = useCallback(() => {
const pos = getPastedBasePos(editor);
if (pos === null) return undefined;
const node = editor.state.doc.nodeAt(pos);
if (!node) return undefined;
const dom = editor.view.nodeDOM(pos) as HTMLElement | null;
const domRect =
dom?.getBoundingClientRect?.() ??
posToDOMRect(editor.view, pos, pos + node.nodeSize);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]);
const dismiss = useCallback(() => {
if (!basePasteMenuKey.getState(editor.state)) return;
editor.view.dispatch(editor.state.tr.setMeta(basePasteMenuKey, null));
requestAnimationFrame(() => {
const activeElement = document.activeElement;
if (
!activeElement ||
activeElement === document.body ||
!activeElement.isConnected
) {
editor.commands.focus(undefined, { scrollIntoView: false });
}
});
}, [editor]);
const embed = useCallback(() => {
const pos = getPastedBasePos(editor);
if (pos === null) return;
dismiss();
if (convertMentionToBaseEmbed(editor, pos)) {
editor.commands.focus(undefined, { scrollIntoView: false });
}
}, [dismiss, editor]);
useEffect(() => setHighlighted(false), [pastedPos]);
// Focus stays in the editor, so capture keys before ProseMirror handles them.
useEffect(() => {
if (!isOpen) return;
const ownsFocus = () =>
editor.view.hasFocus() ||
Boolean(promptRef.current?.contains(document.activeElement));
const onKeyDown = (event: KeyboardEvent) => {
if (!ownsFocus()) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
dismiss();
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
setHighlighted(true);
return;
}
// Until the option is highlighted, Enter is still a new line.
if (event.key === "Enter" && highlighted) {
event.preventDefault();
event.stopPropagation();
embed();
}
};
// Leaving the editor dismisses the prompt, unless focus moved into the prompt itself.
const onBlur = ({ event }: { event: FocusEvent }) => {
const next = event.relatedTarget;
if (next instanceof Node && promptRef.current?.contains(next)) return;
if (editor.isDestroyed) return;
editor.view.dispatch(editor.state.tr.setMeta(basePasteMenuKey, null));
};
window.addEventListener("keydown", onKeyDown, true);
editor.on("blur", onBlur);
return () => {
window.removeEventListener("keydown", onKeyDown, true);
editor.off("blur", onBlur);
};
}, [dismiss, editor, embed, highlighted, isOpen]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey="base-paste-menu"
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "bottom-start",
offset: 10,
flip: true,
shift: true,
}}
shouldShow={shouldShow}
>
{isOpen ? (
<Paper
ref={promptRef}
shadow="md"
radius="md"
withBorder
p={4}
miw={140}
role="listbox"
aria-label={t("Paste as")}
aria-activedescendant={highlighted ? optionId : undefined}
>
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
{highlighted ? t("Inline base") : ""}
</VisuallyHidden>
<Text size="xs" c="dimmed" px={8} py={4}>
{t("Paste as")}
</Text>
<Button
id={optionId}
role="option"
aria-selected={highlighted}
variant={highlighted ? "light" : "subtle"}
color="gray"
size="compact-sm"
fullWidth
justify="flex-start"
className={classes.pasteOption}
onMouseEnter={() => setHighlighted(true)}
onMouseLeave={() => setHighlighted(false)}
onFocus={() => setHighlighted(true)}
onClick={embed}
>
{t("Inline base")}
</Button>
</Paper>
) : null}
</BaseBubbleMenu>
);
}

View file

@ -0,0 +1,151 @@
import { BaseEmbed, Mention } from "@docmost/editor-ext";
import { Editor, type JSONContent } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
BasePasteMenuExtension,
basePasteMenuKey,
} from "@/features/editor/extensions/base-paste-menu";
import { handlePaste } from "@/features/editor/components/common/editor-paste-handler";
import { convertMentionToBaseEmbed } from "./convert-mention-to-base-embed";
const mocks = vi.hoisted(() => ({ getPageById: vi.fn() }));
vi.mock("@/main.tsx", async () => {
const { QueryClient } = await import("@tanstack/react-query");
return { queryClient: new QueryClient() };
});
vi.mock("@/features/page/services/page-service.ts", async (importOriginal) => ({
...(await importOriginal<object>()),
getPageById: mocks.getPageById,
}));
const PAGE_ID = "019a2b3c-0000-7000-8000-000000000001";
const LINK = `${window.location.origin}/s/general/p/roadmap-x1y2z3`;
let editor: Editor;
function createEditor(content: JSONContent[], cursor: number) {
editor = new Editor({
extensions: [StarterKit, Mention, BaseEmbed, BasePasteMenuExtension],
content: { type: "doc", content },
});
editor.commands.setTextSelection(cursor);
}
function mentionPos(): number | null {
let found: number | null = null;
editor.state.doc.descendants((node, pos) => {
if (node.type.name === "mention") found = pos;
});
return found;
}
async function pasteLink({
isBase,
url = LINK,
}: {
isBase: boolean;
url?: string;
}) {
mocks.getPageById.mockResolvedValue({
id: PAGE_ID,
slugId: "x1y2z3",
title: "Roadmap",
isBase,
});
const event = {
clipboardData: {
getData: (type: string) => (type === "text/plain" ? url : ""),
files: [],
},
preventDefault: vi.fn(),
};
handlePaste(editor, event as unknown as ClipboardEvent, "page-id");
await vi.waitFor(() => expect(mentionPos()).not.toBeNull());
}
afterEach(() => editor?.destroy());
describe("pasting a link to a base", () => {
it("offers to embed it", async () => {
createEditor([{ type: "paragraph" }], 1);
await pasteLink({ isBase: true });
expect(basePasteMenuKey.getState(editor.state)).toEqual({
pos: mentionPos(),
});
});
it("offers to embed a link to one of its views", async () => {
createEditor([{ type: "paragraph" }], 1);
await pasteLink({
isBase: true,
url: `${LINK}?view=0195f3c2-1111-7000-8000-000000000abc`,
});
expect(mocks.getPageById).toHaveBeenCalledWith({ pageId: "x1y2z3" });
expect(basePasteMenuKey.getState(editor.state)).toEqual({
pos: mentionPos(),
});
});
it("does not offer it for a document", async () => {
createEditor([{ type: "paragraph" }], 1);
await pasteLink({ isBase: false });
expect(basePasteMenuKey.getState(editor.state)).toBeNull();
});
it("does not offer it where a block cannot go", async () => {
createEditor(
[
{
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "Plan " }],
},
],
6,
);
await pasteLink({ isBase: true });
expect(basePasteMenuKey.getState(editor.state)).toBeNull();
});
});
describe("embedding a pasted base", () => {
it("replaces a mention on its own line with the base", async () => {
createEditor([{ type: "paragraph" }], 1);
await pasteLink({ isBase: true });
expect(convertMentionToBaseEmbed(editor, mentionPos()!)).toBe(true);
// The trailing paragraph comes from the trailing node, as in the page editor.
expect(editor.getJSON().content).toMatchObject([
{ type: "base", attrs: { pageId: PAGE_ID } },
{ type: "paragraph" },
]);
});
it("splits the paragraph around a mention pasted mid-sentence", async () => {
createEditor(
[{ type: "paragraph", content: [{ type: "text", text: "See for dates" }] }],
5,
);
await pasteLink({ isBase: true });
expect(convertMentionToBaseEmbed(editor, mentionPos()!)).toBe(true);
expect(editor.getJSON().content).toMatchObject([
{ type: "paragraph", content: [{ type: "text", text: "See" }] },
{ type: "base", attrs: { pageId: PAGE_ID } },
{ type: "paragraph", content: [{ type: "text", text: "for dates" }] },
]);
});
});

View file

@ -0,0 +1,33 @@
import type { Editor } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { getBlockPlacement } from "@/features/editor/utils/block-placement";
function getEmbedPlacement(doc: ProseMirrorNode, pos: number) {
const mention = doc.nodeAt(pos);
const baseType = doc.type.schema.nodes.base;
if (mention?.type.name !== "mention" || !baseType) return null;
return getBlockPlacement(
doc,
pos,
pos + mention.nodeSize,
baseType.create({ pageId: mention.attrs.entityId }),
);
}
export function canConvertMentionToBaseEmbed(
doc: ProseMirrorNode,
pos: number,
): boolean {
return getEmbedPlacement(doc, pos) !== null;
}
export function convertMentionToBaseEmbed(editor: Editor, pos: number): boolean {
const placement = getEmbedPlacement(editor.state.doc, pos);
if (!placement) return false;
return editor.commands.command(({ tr }) => {
tr.replaceWith(placement.range.from, placement.range.to, placement.content);
return true;
});
}

View file

@ -131,10 +131,12 @@ export const handlePaste = (
}
}
if (INTERNAL_LINK_REGEX.test(clipboardData)) {
// A query, such as a base's ?view=, still links to the same page.
const pageLink = clipboardData.replace(/\?[^#]*/, "");
if (INTERNAL_LINK_REGEX.test(pageLink)) {
// we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault();
const url = clipboardData.trim();
const url = pageLink.trim();
const { from: pos, empty } = editor.state.selection;
const match = INTERNAL_LINK_REGEX.exec(url);

View file

@ -27,3 +27,23 @@
flex-wrap: wrap;
gap: 8px;
}
.chip {
font-weight: 500;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
&:hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
}
&:disabled {
opacity: 0.6;
}
}

View file

@ -99,6 +99,7 @@ export function EmptyPageGetStarted({
key: "base",
label: t("Base"),
icon: IconTable,
color: "blue",
onClick: () => convertMutation.mutate({ pageId }),
disabled: convertMutation.isPending,
},
@ -106,6 +107,7 @@ export function EmptyPageGetStarted({
key: "kanban",
label: t("Kanban"),
icon: IconLayoutKanban,
color: "violet",
onClick: () =>
convertMutation.mutate({ pageId, template: "kanban" }),
disabled: convertMutation.isPending,
@ -118,6 +120,7 @@ export function EmptyPageGetStarted({
key: "template",
label: t("Template"),
icon: IconTemplate,
color: "pink",
onClick: openTemplatePicker,
disabled: false,
},
@ -137,10 +140,17 @@ export function EmptyPageGetStarted({
{chips.map((chip) => (
<Button
key={chip.key}
variant="default"
size="xs"
radius="xl"
leftSection={<chip.icon size={16} />}
variant="subtle"
color="gray"
size="sm"
radius="md"
className={classes.chip}
leftSection={
<chip.icon
size={18}
color={`var(--mantine-color-${chip.color}-6)`}
/>
}
onClick={chip.onClick}
disabled={chip.disabled}
>

View file

@ -8,6 +8,7 @@ import {
IconChevronDown,
IconInfoCircle,
IconLayoutKanban,
IconLayoutNavbar,
IconMath,
IconMathFunction,
IconRotate2,
@ -87,6 +88,13 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
>
{t("Toggle block")}
</Menu.Item>
<Menu.Item
leftSection={<IconLayoutNavbar size={16} />}
disabled={!editor.can().insertTabs()}
onClick={() => editor.chain().focus().insertTabs().run()}
>
{t("Tabs")}
</Menu.Item>
<Menu.Item
leftSection={<IconTag size={16} />}
onClick={() =>

View file

@ -1,6 +1,7 @@
import { getMarkRange, type Editor } from "@tiptap/core";
import { Fragment, type Node as ProseMirrorNode } from "@tiptap/pm/model";
import type { Fragment } from "@tiptap/pm/model";
import { NodeSelection } from "@tiptap/pm/state";
import { getBlockPlacement } from "@/features/editor/utils/block-placement";
export type IntegrationDisplay = "card" | "mention" | "url";
@ -66,74 +67,13 @@ function getCardPlacement(
range: null;
content: null;
} {
const $from = editor.state.doc.resolve(from);
if ($from.parent.type.name !== "paragraph") {
return { canPlace: false, range: null, content: null };
}
const parentStart = $from.start();
const parentEnd = parentStart + $from.parent.content.size;
if (from < parentStart || to > parentEnd || from > to) {
return { canPlace: false, range: null, content: null };
}
const depth = $from.depth;
const parentContainer = $from.node(depth - 1);
const parentIndex = $from.index(depth - 1);
const cardType = editor.state.schema.nodes.integrationCard;
if (!cardType) {
return { canPlace: false, range: null, content: null };
}
const before = trimInlineBoundary(
$from.parent.content.cut(0, from - parentStart),
"end",
);
const after = trimInlineBoundary(
$from.parent.content.cut(to - parentStart),
"start",
);
const replacement: ProseMirrorNode[] = [];
if (before.size) replacement.push($from.parent.copy(before));
replacement.push(cardType.create(attrs));
if (after.size) replacement.push($from.parent.copy(after));
const content = Fragment.fromArray(replacement);
if (!parentContainer.canReplace(parentIndex, parentIndex + 1, content)) {
return { canPlace: false, range: null, content: null };
}
return {
canPlace: true,
range: { from: $from.before(depth), to: $from.after(depth) },
content,
};
}
function trimInlineBoundary(
fragment: Fragment,
boundary: "start" | "end",
): Fragment {
const children: ProseMirrorNode[] = [];
fragment.forEach((child) => children.push(child));
while (children.length) {
const index = boundary === "start" ? 0 : children.length - 1;
const child = children[index];
if (!child?.isText) break;
const text =
boundary === "start"
? child.text?.replace(/^\s+/, "")
: child.text?.replace(/\s+$/, "");
if (text) {
children[index] = child.type.schema.text(text, child.marks);
break;
}
children.splice(index, 1);
}
return Fragment.fromArray(children);
const placement = cardType
? getBlockPlacement(editor.state.doc, from, to, cardType.create(attrs))
: null;
return placement
? { canPlace: true, ...placement }
: { canPlace: false, range: null, content: null };
}
function replaceWithCard(

View file

@ -3,6 +3,8 @@ import { getPageById } from "@/features/page/services/page-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { v7 } from "uuid";
import { extractPageSlugId } from "@/lib";
import { basePasteMenuKey } from "@/features/editor/extensions/base-paste-menu";
import { canConvertMentionToBaseEmbed } from "@/features/editor/components/base-embed/convert-mention-to-base-embed";
export type LinkFn = (
url: string,
@ -42,6 +44,9 @@ export const handleInternalLink =
if (!node) return;
const transaction = view.state.tr.replaceWith(pos, pos, node);
if (page.isBase && canConvertMentionToBaseEmbed(transaction.doc, pos)) {
transaction.setMeta(basePasteMenuKey, { pos });
}
view.dispatch(transaction);
},
() => {

View file

@ -0,0 +1,39 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export type BasePasteMenuState = { pos: number } | null;
export const basePasteMenuKey = new PluginKey<BasePasteMenuState>(
"basePasteMenu",
);
// Position of a just-pasted base mention, where the "Paste as" menu offers its embed.
export const BasePasteMenuExtension = Extension.create({
name: "basePasteMenu",
addProseMirrorPlugins() {
return [
new Plugin({
key: basePasteMenuKey,
state: {
init: (): BasePasteMenuState => null,
apply(tr, prev): BasePasteMenuState {
const meta = tr.getMeta(basePasteMenuKey);
if (meta !== undefined) return meta;
if (!prev) return null;
// Clicking or typing elsewhere dismisses.
if (tr.selectionSet) return null;
// Follow-up transactions (unique ids, trailing node) keep the anchor.
if (tr.docChanged) {
const pos = tr.mapping.map(prev.pos);
return tr.doc.nodeAt(pos)?.type.name === "mention"
? { pos }
: null;
}
return prev;
},
},
}),
];
},
});

View file

@ -104,6 +104,7 @@ import SubpagesView from "@/features/editor/components/subpages/subpages-view.ts
import IntegrationCardView from "@/features/editor/components/integration-link/integration-card-view.tsx";
import IntegrationMentionView from "@/features/editor/components/integration-link/integration-mention-view.tsx";
import { IntegrationPasteMenuExtension } from "@/features/editor/extensions/integration-paste-menu";
import { BasePasteMenuExtension } from "@/features/editor/extensions/base-paste-menu";
import TabsView from "@/features/editor/components/tabs/tabs-view.tsx";
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
@ -408,6 +409,7 @@ export const mainExtensions = [
view: IntegrationMentionView,
}),
IntegrationPasteMenuExtension,
BasePasteMenuExtension,
Status.configure({
view: StatusView,
}),

View file

@ -83,6 +83,7 @@ import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import { IntegrationPasteMenu } from "@/features/editor/components/integration-link/integration-paste-menu.tsx";
import { BasePasteMenu } from "@/features/editor/components/base-embed/base-paste-menu.tsx";
import { getInstalledIntegrations } from "@/features/integration/services/integration-service";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import { useTranslation } from "react-i18next";
@ -527,6 +528,7 @@ function CollabPageEditor({
<DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} />
<IntegrationPasteMenu editor={editor} />
<BasePasteMenu editor={editor} />
</div>
)}
{editor && !editorIsEditable && (editable || canComment) && (

View file

@ -0,0 +1,74 @@
import { Fragment, type Node as ProseMirrorNode } from "@tiptap/pm/model";
export type BlockPlacement = {
range: { from: number; to: number };
content: Fragment;
};
// Swaps an inline range for a block, splitting its paragraph around it; null where the block can't go.
export function getBlockPlacement(
doc: ProseMirrorNode,
from: number,
to: number,
block: ProseMirrorNode,
): BlockPlacement | null {
const $from = doc.resolve(from);
if ($from.parent.type.name !== "paragraph") return null;
const parentStart = $from.start();
const parentEnd = parentStart + $from.parent.content.size;
if (from < parentStart || to > parentEnd || from > to) return null;
const depth = $from.depth;
const parentContainer = $from.node(depth - 1);
const parentIndex = $from.index(depth - 1);
const before = trimInlineBoundary(
$from.parent.content.cut(0, from - parentStart),
"end",
);
const after = trimInlineBoundary(
$from.parent.content.cut(to - parentStart),
"start",
);
const replacement: ProseMirrorNode[] = [];
if (before.size) replacement.push($from.parent.copy(before));
replacement.push(block);
if (after.size) replacement.push($from.parent.copy(after));
const content = Fragment.fromArray(replacement);
if (!parentContainer.canReplace(parentIndex, parentIndex + 1, content)) {
return null;
}
return {
range: { from: $from.before(depth), to: $from.after(depth) },
content,
};
}
function trimInlineBoundary(
fragment: Fragment,
boundary: "start" | "end",
): Fragment {
const children: ProseMirrorNode[] = [];
fragment.forEach((child) => children.push(child));
while (children.length) {
const index = boundary === "start" ? 0 : children.length - 1;
const child = children[index];
if (!child?.isText) break;
const text =
boundary === "start"
? child.text?.replace(/^\s+/, "")
: child.text?.replace(/\s+$/, "");
if (text) {
children[index] = child.type.schema.text(text, child.marks);
break;
}
children.splice(index, 1);
}
return Fragment.fromArray(children);
}

View file

@ -52,13 +52,21 @@ export function usePageQuery(
});
useEffect(() => {
if (query.data) {
if (isValidUuid(pageInput.pageId)) {
queryClient.setQueryData(["pages", query.data.slugId], query.data);
} else {
queryClient.setQueryData(["pages", query.data.id], query.data);
}
const cached = queryClient.getQueryState<IPage>([
"pages",
pageInput.pageId,
]);
if (!cached?.data) return;
const mirrorKey = isValidUuid(pageInput.pageId)
? ["pages", cached.data.slugId]
: ["pages", cached.data.id];
const mirrored = queryClient.getQueryState(mirrorKey);
if (mirrored?.data && mirrored.dataUpdatedAt >= cached.dataUpdatedAt) {
return;
}
queryClient.setQueryData(mirrorKey, cached.data, {
updatedAt: cached.dataUpdatedAt,
});
}, [query.data]);
return query;

View file

@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.96.0",
"version": "0.97.0",
"description": "",
"author": "",
"private": true,
@ -82,7 +82,7 @@
"csv-stringify": "6.8.0",
"fast-bm25": "0.0.5",
"fs-extra": "11.3.4",
"happy-dom": "20.8.9",
"happy-dom": "21.0.4",
"ioredis": "5.10.1",
"js-tiktoken": "1.0.21",
"jsonwebtoken": "9.0.3",
@ -90,14 +90,14 @@
"kysely-migration-cli": "0.4.2",
"kysely-postgres-js": "3.0.0",
"ldapts": "8.1.7",
"mammoth": "1.12.0",
"mammoth": "1.13.0",
"mime-types": "3.0.2",
"msgpackr": "1.11.9",
"nanoid": "5.1.16",
"nestjs-cls": "6.2.0",
"nestjs-kysely": "3.1.2",
"nestjs-pino": "4.6.1",
"nodemailer": "10.0.10",
"nodemailer": "10.1.7",
"openid-client": "6.8.2",
"otpauth": "9.5.0",
"p-limit": "7.3.0",
@ -120,10 +120,10 @@
"tlds": "1.261.0",
"tmp-promise": "3.0.3",
"typesense": "3.0.5",
"undici": "7.29.1",
"undici": "7.30.1",
"ws": "8.21.3",
"yauzl": "3.4.0",
"zod": "4.3.6"
"zod": "4.6.7"
},
"devDependencies": {
"@eslint/js": "^9.28.0",

View file

@ -11,11 +11,17 @@ import { IntegrationRepo } from './repos/integration.repo';
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
import { IntegrationListener } from './integration.listener';
import { IntegrationProcessor } from './integration.processor';
import { EnvironmentService } from '../../integrations/environment/environment.service';
@Module({
controllers: [IntegrationController, OAuthController, UnfurlController],
providers: [
IntegrationRegistry,
{
provide: IntegrationRegistry,
useFactory: (environmentService: EnvironmentService) =>
new IntegrationRegistry(environmentService.isCloud()),
inject: [EnvironmentService],
},
IntegrationService,
IntegrationConnectionService,
OAuthService,

View file

@ -61,7 +61,7 @@ describe('GitHubProvider host matching', () => {
const { provider, unfurlPullRequest } = buildProvider();
await unfurlWith(provider, PUBLIC_PR);
expect(unfurlPullRequest).toHaveBeenCalledWith(
'token', 'https://api.github.com', 'o', 'r', 1, PUBLIC_PR,
'Bearer token', 'https://api.github.com', 'o', 'r', 1, PUBLIC_PR,
);
});
});
@ -87,7 +87,7 @@ describe('GitHubProvider host matching', () => {
const { provider, unfurlPullRequest } = buildProvider();
await unfurlWith(provider, GHE_PR);
expect(unfurlPullRequest).toHaveBeenCalledWith(
'token', `${GHE_BASE}/api/v3`, 'o', 'r', 1, GHE_PR,
'Bearer token', `${GHE_BASE}/api/v3`, 'o', 'r', 1, GHE_PR,
);
});

View file

@ -5,6 +5,7 @@ import {
LinkDescription,
OAuthConfig,
ProviderAccount,
PublicUnfurlOpts,
ResolveAccountOpts,
UnfurlPattern,
UnfurlOpts,
@ -13,6 +14,7 @@ import {
import { GitHubService } from './github.service';
import { buildGitHubPatterns } from './github-patterns';
import { normalizeBaseUrl } from '../../utils/integration.utils';
import { credentialEnvKey } from '../../oauth/credential-env';
const DEFAULT_BASE_URL = 'https://github.com';
@ -63,13 +65,35 @@ export class GitHubProvider extends IntegrationProvider {
async resolveAccount(opts: ResolveAccountOpts): Promise<ProviderAccount> {
return this.githubService.getAccount(
opts.accessToken,
`Bearer ${opts.accessToken}`,
this.resolveApiBaseUrl(),
);
}
async unfurl(opts: UnfurlOpts): Promise<UnfurlResult> {
const { match, patternType, accessToken, url } = opts;
return this.unfurlWith(`Bearer ${opts.accessToken}`, opts);
}
// The OAuth app's own credentials only see public repos, so private ones 404.
async unfurlPublic(opts: PublicUnfurlOpts): Promise<UnfurlResult> {
const clientId =
process.env[credentialEnvKey('CLIENT_ID', this.definition.type)];
const clientSecret =
process.env[credentialEnvKey('CLIENT_SECRET', this.definition.type)];
if (!clientId || !clientSecret) {
throw new Error('GitHub client ID or secret is not set');
}
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString(
'base64',
);
return this.unfurlWith(`Basic ${credentials}`, opts);
}
private async unfurlWith(
authorization: string,
opts: PublicUnfurlOpts,
): Promise<UnfurlResult> {
const { match, patternType, url } = opts;
const apiBaseUrl = this.resolveApiBaseUrl();
const owner = match[1];
const repo = match[2];
@ -78,33 +102,33 @@ export class GitHubProvider extends IntegrationProvider {
case 'github-pr': {
const number = parseInt(match[3], 10);
return this.githubService.unfurlPullRequest(
accessToken, apiBaseUrl, owner, repo, number, url,
authorization, apiBaseUrl, owner, repo, number, url,
);
}
case 'github-issue': {
const number = parseInt(match[3], 10);
return this.githubService.unfurlIssue(
accessToken, apiBaseUrl, owner, repo, number, url,
authorization, apiBaseUrl, owner, repo, number, url,
);
}
case 'github-repo':
return this.githubService.unfurlRepo(
accessToken, apiBaseUrl, owner, repo, url,
authorization, apiBaseUrl, owner, repo, url,
);
case 'github-commit': {
const sha = match[3];
return this.githubService.unfurlCommit(
accessToken, apiBaseUrl, owner, repo, sha, url,
authorization, apiBaseUrl, owner, repo, sha, url,
);
}
case 'github-pr-commit': {
const sha = match[4];
return this.githubService.unfurlCommit(
accessToken, apiBaseUrl, owner, repo, sha, url,
authorization, apiBaseUrl, owner, repo, sha, url,
);
}
@ -122,7 +146,7 @@ export class GitHubProvider extends IntegrationProvider {
case 'github-issues-list':
case 'github-releases-list':
return this.githubService.unfurlCollectionPage(
accessToken, apiBaseUrl, owner, repo, patternType.replace('github-', ''), url,
authorization, apiBaseUrl, owner, repo, patternType.replace('github-', ''), url,
);
default:

View file

@ -28,11 +28,11 @@ const BODY = {
const service = new GitHubService();
const REQUESTS: [string, (api: string, owner: string, repo: string) => Promise<unknown>, string][] = [
['pull request', (api, owner, repo) => service.unfurlPullRequest('token', api, owner, repo, 1, PAGE), '/pulls/1'],
['issue', (api, owner, repo) => service.unfurlIssue('token', api, owner, repo, 2, PAGE), '/issues/2'],
['repo', (api, owner, repo) => service.unfurlRepo('token', api, owner, repo, PAGE), ''],
['commit', (api, owner, repo) => service.unfurlCommit('token', api, owner, repo, 'abc1234', PAGE), '/commits/abc1234'],
['collection page', (api, owner, repo) => service.unfurlCollectionPage('token', api, owner, repo, 'pulls-list', PAGE), ''],
['pull request', (api, owner, repo) => service.unfurlPullRequest('Bearer token', api, owner, repo, 1, PAGE), '/pulls/1'],
['issue', (api, owner, repo) => service.unfurlIssue('Bearer token', api, owner, repo, 2, PAGE), '/issues/2'],
['repo', (api, owner, repo) => service.unfurlRepo('Bearer token', api, owner, repo, PAGE), ''],
['commit', (api, owner, repo) => service.unfurlCommit('Bearer token', api, owner, repo, 'abc1234', PAGE), '/commits/abc1234'],
['collection page', (api, owner, repo) => service.unfurlCollectionPage('Bearer token', api, owner, repo, 'pulls-list', PAGE), ''],
];
function requestedUrls(): string[] {
@ -144,3 +144,57 @@ describe('GitHub connected account', () => {
);
});
});
describe('GitHub public unfurl', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = {
...originalEnv,
INTEGRATION_GITHUB_CLIENT_ID: 'app-id',
INTEGRATION_GITHUB_CLIENT_SECRET: 'app-secret',
};
delete process.env.INTEGRATION_GITHUB_URL;
fetchMock.mockReset();
fetchMock.mockImplementation(
async () =>
new Response(JSON.stringify(BODY), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
});
afterEach(() => {
process.env = originalEnv;
});
function unfurlPublic(url: string) {
const provider = new GitHubProvider(new GitHubService());
for (const pattern of provider.getUnfurlPatterns({})) {
const match = url.match(pattern.regex);
if (match) {
return provider.unfurlPublic({ url, match, patternType: pattern.type });
}
}
throw new Error(`No GitHub pattern matches ${url}`);
}
it('reads a pull request with the app client ID and secret instead of a member token', async () => {
await expect(unfurlPublic(PAGE)).resolves.toMatchObject({
title: 'Add dark mode',
});
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.github.com/repos/o/r/pulls/1');
// base64 of "app-id:app-secret"
expect(init.headers.Authorization).toBe('Basic YXBwLWlkOmFwcC1zZWNyZXQ=');
});
it('never calls GitHub without the app credentials', async () => {
delete process.env.INTEGRATION_GITHUB_CLIENT_SECRET;
await expect(unfurlPublic(PAGE)).rejects.toThrow();
expect(fetchMock).not.toHaveBeenCalled();
});
});

View file

@ -11,10 +11,10 @@ import { repoApiPath } from './github.utils';
@Injectable()
export class GitHubService {
async getAccount(
accessToken: string,
authorization: string,
apiBaseUrl: string,
): Promise<ProviderAccount> {
const data = await this.apiGet(accessToken, apiBaseUrl, '/user');
const data = await this.apiGet(authorization, apiBaseUrl, '/user');
return toProviderAccount({
id: data.id,
displayName: data.name,
@ -23,7 +23,7 @@ export class GitHubService {
}
async unfurlPullRequest(
accessToken: string,
authorization: string,
apiBaseUrl: string,
owner: string,
repo: string,
@ -31,7 +31,7 @@ export class GitHubService {
url: string,
): Promise<UnfurlResult> {
const data = await this.apiGet(
accessToken,
authorization,
apiBaseUrl,
`${repoApiPath(owner, repo)}/pulls/${number}`,
);
@ -66,7 +66,7 @@ export class GitHubService {
}
async unfurlIssue(
accessToken: string,
authorization: string,
apiBaseUrl: string,
owner: string,
repo: string,
@ -74,7 +74,7 @@ export class GitHubService {
url: string,
): Promise<UnfurlResult> {
const data = await this.apiGet(
accessToken,
authorization,
apiBaseUrl,
`${repoApiPath(owner, repo)}/issues/${number}`,
);
@ -107,14 +107,14 @@ export class GitHubService {
}
async unfurlRepo(
accessToken: string,
authorization: string,
apiBaseUrl: string,
owner: string,
repo: string,
url: string,
): Promise<UnfurlResult> {
const data = await this.apiGet(
accessToken,
authorization,
apiBaseUrl,
repoApiPath(owner, repo),
);
@ -143,7 +143,7 @@ export class GitHubService {
}
async unfurlCommit(
accessToken: string,
authorization: string,
apiBaseUrl: string,
owner: string,
repo: string,
@ -151,7 +151,7 @@ export class GitHubService {
url: string,
): Promise<UnfurlResult> {
const data = await this.apiGet(
accessToken,
authorization,
apiBaseUrl,
`${repoApiPath(owner, repo)}/commits/${sha}`,
);
@ -217,7 +217,7 @@ export class GitHubService {
}
async unfurlCollectionPage(
accessToken: string,
authorization: string,
apiBaseUrl: string,
owner: string,
repo: string,
@ -225,7 +225,7 @@ export class GitHubService {
url: string,
): Promise<UnfurlResult> {
const data = await this.apiGet(
accessToken,
authorization,
apiBaseUrl,
repoApiPath(owner, repo),
);
@ -265,13 +265,13 @@ export class GitHubService {
}
private async apiGet(
accessToken: string,
authorization: string,
apiBaseUrl: string,
path: string,
): Promise<any> {
const response = await providerApiFetch('GitHub', `${apiBaseUrl}${path}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Authorization: authorization,
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'Docmost',
},

View file

@ -140,6 +140,8 @@ export type UnfurlOpts = {
integrationId: string;
};
export type PublicUnfurlOpts = Pick<UnfurlOpts, 'url' | 'match' | 'patternType'>;
// UnfurlService shows no card for it instead of logging an error.
export class UnfurlForbiddenError extends Error {
constructor(message = 'Not authorized to unfurl this link') {
@ -221,6 +223,9 @@ export abstract class IntegrationProvider {
unfurl?(opts: UnfurlOpts): Promise<UnfurlResult>;
// For members without a connection; must only read public data, since every workspace shares the card.
unfurlPublic?(opts: PublicUnfurlOpts): Promise<UnfurlResult>;
// Tokenless summary for the connect prompt, e.g. "Pull Request #13337".
describeLink?(
patternType: string,

View file

@ -22,4 +22,30 @@ describe('IntegrationRegistry', () => {
'Integration provider "linear" is already registered',
);
});
describe('available integrations', () => {
const types = ['github', 'figma', 'linear', 'jira', 'gitlab'];
it('offers only GitHub, GitLab and Linear on Cloud', () => {
const registry = new IntegrationRegistry(true);
types.forEach((type) => registry.register(provider(type)));
expect(registry.getAvailableIntegrations().map((d) => d.type)).toEqual([
'github',
'linear',
'gitlab',
]);
// Existing installs of the others keep unfurling.
expect(registry.getProvider('figma')).toBeDefined();
});
it('offers every provider on self-hosted', () => {
const registry = new IntegrationRegistry();
types.forEach((type) => registry.register(provider(type)));
expect(registry.getAvailableIntegrations().map((d) => d.type)).toEqual(
types,
);
});
});
});

View file

@ -4,15 +4,23 @@ import {
IntegrationProvider,
} from './integration-provider.interface';
// Docmost Cloud only offers the providers it has OAuth apps for.
const CLOUD_INTEGRATION_TYPES = new Set(['github', 'gitlab', 'linear']);
@Injectable()
export class IntegrationRegistry {
private providers = new Map<string, IntegrationProvider>();
constructor(private readonly cloud = false) {}
register(provider: IntegrationProvider): void {
const { type } = provider.definition;
if (this.providers.has(type)) {
throw new Error(`Integration provider "${type}" is already registered`);
}
if (this.cloud && !CLOUD_INTEGRATION_TYPES.has(type)) {
provider.definition.hidden = true;
}
this.providers.set(type, provider);
}

View file

@ -1,16 +1,32 @@
import * as crypto from 'crypto';
import { Logger } from '@nestjs/common';
import { UnfurlService } from './unfurl.service';
import { IntegrationRegistry } from '../registry/integration-registry';
import { GitHubProvider } from '../providers/github/github.provider';
import {
IntegrationDefinition,
IntegrationProvider,
ProviderApiError,
TokenExpiredError,
TokenInvalidError,
} from '../registry/integration-provider.interface';
const GHE_BASE = 'https://github.acme.com';
const CONNECTED_AT = new Date('2026-10-01T09:00:00Z');
const ORIGINAL_ENV = process.env;
// A configured server always has the app credentials, since GitHub cannot be installed without them.
beforeEach(() => {
process.env = {
...ORIGINAL_ENV,
INTEGRATION_GITHUB_CLIENT_ID: 'app-id',
INTEGRATION_GITHUB_CLIENT_SECRET: 'app-secret',
};
});
afterEach(() => {
process.env = ORIGINAL_ENV;
});
class WorkspaceScopedProvider extends IntegrationProvider {
definition: IntegrationDefinition = {
@ -102,7 +118,7 @@ describe('UnfurlService provider resolution', () => {
title: 'PR',
});
expect(unfurlPullRequest).toHaveBeenCalledWith(
'token', apiBaseUrl, 'o', 'r', 1, url,
'Bearer token', apiBaseUrl, 'o', 'r', 1, url,
);
},
);
@ -216,16 +232,42 @@ describe('UnfurlService cache', () => {
});
it('asks a disconnected member to connect instead of serving their cached card', async () => {
const { service, connectionRepo, redis } = build();
redis.get.mockResolvedValue(cachedCard);
const { service, unfurlPullRequest, connectionRepo, redis } = build();
redis.get.mockImplementation(async (key: string) =>
key.startsWith('unfurl:w1:') ? cachedCard : null,
);
connectionRepo.findByIntegrationAndUser.mockResolvedValue(undefined);
unfurlPullRequest.mockRejectedValue(
new ProviderApiError('GitHub', 404, 'Not Found'),
);
await expect(service.unfurl(url, 'u1', 'w1')).resolves.toMatchObject({
needsConnection: true,
});
expect(redis.get).not.toHaveBeenCalled();
expect(redis.set).not.toHaveBeenCalled();
expect(redis.get).not.toHaveBeenCalledWith(
expect.stringMatching(/^unfurl:w1:/),
);
});
it('checks a link the member cannot see again a minute later', async () => {
const { service, unfurlPullRequest, redis } = build();
unfurlPullRequest.mockRejectedValue(
new ProviderApiError('GitHub', 404, 'Not Found'),
);
const error = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
await expect(service.unfurl(url, 'u1', 'w1')).resolves.toBeNull();
expect(redis.set).toHaveBeenCalledWith(
`unfurl:w1:u1:c1:${CONNECTED_AT.getTime()}:${urlDigest}`,
'null',
'EX',
60,
);
error.mockRestore();
});
it('serves no cached card once the integration is uninstalled', async () => {
@ -322,3 +364,68 @@ describe('UnfurlService cache', () => {
});
});
});
describe('UnfurlService public cards', () => {
const url = 'https://github.com/o/r/pull/1';
let warn: jest.SpyInstance;
beforeEach(() => {
delete process.env.INTEGRATION_GITHUB_URL;
warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
});
afterEach(() => warn.mockRestore());
function buildDisconnected() {
const built = build();
built.connectionRepo.findByIntegrationAndUser.mockResolvedValue(undefined);
return built;
}
it('shows a member without a connection the card for a public repo', async () => {
const { service, unfurlPullRequest } = buildDisconnected();
await expect(service.unfurl(url, 'u1', 'w1')).resolves.toEqual({
title: 'PR',
});
// base64 of "app-id:app-secret"
expect(unfurlPullRequest).toHaveBeenCalledWith(
'Basic YXBwLWlkOmFwcC1zZWNyZXQ=', 'https://api.github.com', 'o', 'r', 1, url,
);
});
it('fetches a public card once for every member and workspace', async () => {
const { service, unfurlPullRequest, redis } = buildDisconnected();
const store = new Map<string, string>();
redis.get.mockImplementation(async (key: string) => store.get(key) ?? null);
redis.set.mockImplementation(async (key: string, value: string) => {
store.set(key, value);
return 'OK';
});
await service.unfurl(url, 'u1', 'w1');
await expect(service.unfurl(url, 'u2', 'w2')).resolves.toEqual({
title: 'PR',
});
expect(unfurlPullRequest).toHaveBeenCalledTimes(1);
});
it.each([
['a repo that is not public', new ProviderApiError('GitHub', 404, 'Not Found'), 0],
['a rate limit', new ProviderApiError('GitHub', 403, 'rate limit exceeded'), 1],
['rejected app credentials', new TokenInvalidError(), 1],
])('asks to connect after %s and retries a minute later', async (_, error, warnings) => {
const { service, unfurlPullRequest, redis } = buildDisconnected();
unfurlPullRequest.mockRejectedValue(error);
await expect(service.unfurl(url, 'u1', 'w1')).resolves.toMatchObject({
needsConnection: true,
});
expect(redis.set).toHaveBeenCalledWith(expect.any(String), 'null', 'EX', 60);
expect(warn).toHaveBeenCalledTimes(warnings);
});
});

View file

@ -19,7 +19,7 @@ import type { Redis } from 'ioredis';
import * as crypto from 'crypto';
const UNFURL_CACHE_TTL = 300; // 5 minutes
// Short negative cache for transient failures; a 404 uses the normal TTL.
// Short negative cache, so a card shows up soon after access changes.
const UNFURL_ERROR_CACHE_TTL = 60;
const UNFURL_CACHE_PREFIX = 'unfurl:';
@ -71,6 +71,15 @@ export class UnfurlService {
if (connectionScope === 'workspace') {
return null;
}
const publicResult = await this.unfurlPublic(
provider,
patternType,
match,
url,
);
if (publicResult) {
return publicResult;
}
// Not cached, so the card loads once the user connects.
return this.buildNeedsConnection(
provider,
@ -152,11 +161,7 @@ export class UnfurlService {
);
}
this.logger.error(`Unfurl failed for ${url}: ${(err as Error).message}`);
const ttl =
err instanceof ProviderApiError && err.status === 404
? UNFURL_CACHE_TTL
: UNFURL_ERROR_CACHE_TTL;
await this.cacheNull(cacheKey, ttl);
await this.cacheNull(cacheKey, UNFURL_ERROR_CACHE_TTL);
return null;
}
}
@ -165,6 +170,50 @@ export class UnfurlService {
await this.redis.set(cacheKey, 'null', 'EX', ttl);
}
// One cached card per URL for every member and workspace.
private async unfurlPublic(
provider: IntegrationProvider,
patternType: string,
match: RegExpMatchArray,
url: string,
): Promise<UnfurlResult | null> {
if (!provider.unfurlPublic) {
return null;
}
const hash = crypto.createHash('sha256').update(url).digest('hex');
const cacheKey = `${UNFURL_CACHE_PREFIX}public:${hash}`;
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
try {
const unfurlResult = await provider.unfurlPublic({
url,
match,
patternType,
});
await this.redis.set(
cacheKey,
JSON.stringify(unfurlResult),
'EX',
UNFURL_CACHE_TTL,
);
return unfurlResult;
} catch (err) {
// A 404 is the normal answer for a private repo.
const notFound = err instanceof ProviderApiError && err.status === 404;
if (!notFound) {
this.logger.warn(
`Public unfurl failed for ${url}: ${(err as Error).message}`,
);
}
await this.cacheNull(cacheKey, UNFURL_ERROR_CACHE_TTL);
return null;
}
}
private buildNeedsConnection(
provider: IntegrationProvider,
integrationId: string,

View file

@ -0,0 +1,16 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreatePageDto } from './create-page.dto';
describe('CreatePageDto', () => {
it('defaults format to json when content is sent without one', async () => {
const dto = plainToInstance(CreatePageDto, {
spaceId: '0199f2a4-8c1e-7b3a-9d4e-2f6a8b1c3d5e',
content: { type: 'doc', content: [] },
});
const errors = await validate(dto);
expect(errors.flatMap((e) => Object.keys(e.constraints ?? {}))).toEqual([]);
expect(dto.format).toBe('json');
});
});

View file

@ -32,5 +32,5 @@ export class CreatePageDto {
@ValidateIf((o) => o.content !== undefined)
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
@IsIn(['json', 'markdown', 'html'])
format?: ContentFormat;
format?: ContentFormat = 'json';
}

View file

@ -0,0 +1,17 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UpdatePageDto } from './update-page.dto';
describe('UpdatePageDto', () => {
it('defaults format to json when content is sent without one', async () => {
const dto = plainToInstance(UpdatePageDto, {
pageId: '0199f2a4-8c1e-7b3a-9d4e-2f6a8b1c3d5e',
content: { type: 'doc', content: [] },
operation: 'replace',
});
const errors = await validate(dto);
expect(errors.flatMap((e) => Object.keys(e.constraints ?? {}))).toEqual([]);
expect(dto.format).toBe('json');
});
});

View file

@ -20,5 +20,5 @@ export class UpdatePageDto extends PartialType(CreatePageDto) {
@ValidateIf((o) => o.content !== undefined)
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
@IsIn(['json', 'markdown', 'html'])
format?: ContentFormat;
format?: ContentFormat = 'json';
}

@ -1 +1 @@
Subproject commit 7eb0ef1dfa2f5ed9e6331e8d068cbbe15be25d7d
Subproject commit 481e7791f8bbcdc368e31c012f34cc52a4227587

View file

@ -12,6 +12,7 @@ import {
OAUTH_AUTHORIZE_THROTTLER,
SIEM_TEST_THROTTLER,
UNFURL_THROTTLER,
CLOUD_EMAIL_THROTTLER,
} from './throttler-names';
import Redis from 'ioredis';
@ -31,6 +32,7 @@ import Redis from 'ioredis';
{ name: OAUTH_AUTHORIZE_THROTTLER, ttl: 60_000, limit: 30 },
{ name: SIEM_TEST_THROTTLER, ttl: 60_000, limit: 10 },
{ name: UNFURL_THROTTLER, ttl: 60_000, limit: 120 },
{ name: CLOUD_EMAIL_THROTTLER, ttl: 60_000, limit: 5 },
],
errorMessage: 'Too many requests',
storage: new ThrottlerStorageRedisService(

View file

@ -5,6 +5,7 @@ export const OAUTH_TOKEN_THROTTLER = 'oauth-token';
export const OAUTH_AUTHORIZE_THROTTLER = 'oauth-authorize';
export const SIEM_TEST_THROTTLER = 'siem-test';
export const UNFURL_THROTTLER = 'integration-unfurl';
export const CLOUD_EMAIL_THROTTLER = 'cloud-email';
// Every named throttler must appear here; spread it in @SkipThrottle and re-enable per name with false.
export const ALL_NAMED_THROTTLERS_SKIPPED: Record<string, boolean> = {
@ -15,4 +16,5 @@ export const ALL_NAMED_THROTTLERS_SKIPPED: Record<string, boolean> = {
[OAUTH_AUTHORIZE_THROTTLER]: true,
[SIEM_TEST_THROTTLER]: true,
[UNFURL_THROTTLER]: true,
[CLOUD_EMAIL_THROTTLER]: true,
};

View file

@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.96.0",
"version": "0.97.0",
"private": true,
"scripts": {
"build": "nx run-many -t build",
@ -54,7 +54,6 @@
"@tiptap/extension-typography": "3.31.3",
"@tiptap/extension-unique-id": "3.31.3",
"@tiptap/extension-youtube": "3.31.3",
"@tiptap/html": "3.31.3",
"@tiptap/pm": "3.31.3",
"@tiptap/react": "3.31.3",
"@tiptap/starter-kit": "3.31.3",
@ -65,7 +64,7 @@
"date-fns": "4.1.0",
"diff": "8.0.3",
"docx": "9.7.1",
"dompurify": "3.4.16",
"dompurify": "3.4.17",
"fractional-indexing-jittered": "1.0.0",
"highlight.js": "11.11.1",
"image-dimensions": "2.5.0",

File diff suppressed because it is too large Load diff

View file

@ -7,20 +7,20 @@ overrides:
prosemirror-changeset: 2.4.0
glob: 13.0.6
ws: 8.21.3
dompurify: 3.4.16
dompurify: 3.4.17
mermaid: 11.16.1
katex: 0.19.0
undici: 7.29.1
undici: 7.30.1
tmp: 0.2.7
nanoid@^3: 3.3.18
lodash-es: 4.18.1
flatted: 3.4.2
find-my-way: 9.7.0
fastify: 5.12.5
fastify: 5.12.7
yaml@>=2.0.0 <2.8.3: 2.8.3
brace-expansion@^5: 5.0.12
axios: 1.20.0
fast-uri: 3.1.8
fast-uri: 3.1.9
form-data@>=4.0.0 <4.0.6: 4.0.6
nanoid@>=4.0.0 <5.1.16: 5.1.16
esbuild@>=0.27.3 <0.28.1: 0.28.1