chore(sync): sync updates from docmost/docmost
This commit is contained in:
commit
678615d162
70 changed files with 2032 additions and 603 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.96.0",
|
"version": "0.97.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
"semver": "7.7.4",
|
"semver": "7.7.4",
|
||||||
"socket.io-client": "4.8.3",
|
"socket.io-client": "4.8.3",
|
||||||
"yet-another-react-lightbox": "^3.32.2",
|
"yet-another-react-lightbox": "^3.32.2",
|
||||||
"zod": "4.3.6"
|
"zod": "4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.28.0",
|
"@eslint/js": "9.28.0",
|
||||||
|
|
@ -94,7 +94,7 @@
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.57.1",
|
"typescript-eslint": "8.57.1",
|
||||||
"vite": "8.0.16",
|
"vite": "8.3.4",
|
||||||
"vitest": "4.1.11"
|
"vitest": "4.1.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1573,5 +1573,6 @@
|
||||||
"Move right": "Nach rechts verschieben",
|
"Move right": "Nach rechts verschieben",
|
||||||
"Delete tab": "Tab löschen",
|
"Delete tab": "Tab löschen",
|
||||||
"Tab label": "Tab-Beschriftung",
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1573,5 +1573,6 @@
|
||||||
"Move right": "Move right",
|
"Move right": "Move right",
|
||||||
"Delete tab": "Delete tab",
|
"Delete tab": "Delete tab",
|
||||||
"Tab label": "Tab label",
|
"Tab label": "Tab label",
|
||||||
"Tab actions for {{name}}": "Tab actions for {{name}}"
|
"Tab actions for {{name}}": "Tab actions for {{name}}",
|
||||||
|
"Inline base": "Inline base"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1573,5 +1573,6 @@
|
||||||
"Move right": "Mover a la derecha",
|
"Move right": "Mover a la derecha",
|
||||||
"Delete tab": "Eliminar pestaña",
|
"Delete tab": "Eliminar pestaña",
|
||||||
"Tab label": "Etiqueta de la 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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1573,5 +1573,6 @@
|
||||||
"Move right": "Déplacer vers la droite",
|
"Move right": "Déplacer vers la droite",
|
||||||
"Delete tab": "Supprimer l’onglet",
|
"Delete tab": "Supprimer l’onglet",
|
||||||
"Tab label": "Libellé de 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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1573,5 +1573,6 @@
|
||||||
"Move right": "Sposta a destra",
|
"Move right": "Sposta a destra",
|
||||||
"Delete tab": "Elimina scheda",
|
"Delete tab": "Elimina scheda",
|
||||||
"Tab label": "Etichetta 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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1573,5 +1573,6 @@
|
||||||
"Move right": "右に移動",
|
"Move right": "右に移動",
|
||||||
"Delete tab": "タブを削除",
|
"Delete tab": "タブを削除",
|
||||||
"Tab label": "タブラベル",
|
"Tab label": "タブラベル",
|
||||||
"Tab actions for {{name}}": "{{name}} のタブアクション"
|
"Tab actions for {{name}}": "{{name}} のタブアクション",
|
||||||
|
"Inline base": "インラインベース"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1573,5 +1573,6 @@
|
||||||
"Move right": "오른쪽으로 이동",
|
"Move right": "오른쪽으로 이동",
|
||||||
"Delete tab": "탭 삭제",
|
"Delete tab": "탭 삭제",
|
||||||
"Tab label": "탭 레이블",
|
"Tab label": "탭 레이블",
|
||||||
"Tab actions for {{name}}": "{{name}} 탭 작업"
|
"Tab actions for {{name}}": "{{name}} 탭 작업",
|
||||||
|
"Inline base": "인라인 베이스"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1573,5 +1573,6 @@
|
||||||
"Move right": "Naar rechts verplaatsen",
|
"Move right": "Naar rechts verplaatsen",
|
||||||
"Delete tab": "Tabblad verwijderen",
|
"Delete tab": "Tabblad verwijderen",
|
||||||
"Tab label": "Tabbladlabel",
|
"Tab label": "Tabbladlabel",
|
||||||
"Tab actions for {{name}}": "Tabbladacties voor {{name}}"
|
"Tab actions for {{name}}": "Tabbladacties voor {{name}}",
|
||||||
|
"Inline base": "Inline basis"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1573,5 +1573,6 @@
|
||||||
"Move right": "Mover para a direita",
|
"Move right": "Mover para a direita",
|
||||||
"Delete tab": "Excluir aba",
|
"Delete tab": "Excluir aba",
|
||||||
"Tab label": "Rótulo da 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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1573,5 +1573,6 @@
|
||||||
"Move right": "Переместить вправо",
|
"Move right": "Переместить вправо",
|
||||||
"Delete tab": "Удалить вкладку",
|
"Delete tab": "Удалить вкладку",
|
||||||
"Tab label": "Название вкладки",
|
"Tab label": "Название вкладки",
|
||||||
"Tab actions for {{name}}": "Действия с вкладкой для {{name}}"
|
"Tab actions for {{name}}": "Действия с вкладкой для {{name}}",
|
||||||
|
"Inline base": "Базовый встроенный элемент"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1573,5 +1573,6 @@
|
||||||
"Move right": "Перемістити праворуч",
|
"Move right": "Перемістити праворуч",
|
||||||
"Delete tab": "Видалити вкладку",
|
"Delete tab": "Видалити вкладку",
|
||||||
"Tab label": "Назва вкладки",
|
"Tab label": "Назва вкладки",
|
||||||
"Tab actions for {{name}}": "Дії вкладки для {{name}}"
|
"Tab actions for {{name}}": "Дії вкладки для {{name}}",
|
||||||
|
"Inline base": "Вбудована основа"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1573,5 +1573,6 @@
|
||||||
"Move right": "右移",
|
"Move right": "右移",
|
||||||
"Delete tab": "删除标签页",
|
"Delete tab": "删除标签页",
|
||||||
"Tab label": "标签页标签",
|
"Tab label": "标签页标签",
|
||||||
"Tab actions for {{name}}": "{{name}}的标签页操作"
|
"Tab actions for {{name}}": "{{name}}的标签页操作",
|
||||||
|
"Inline base": "内联基础"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,12 @@ type CellEmailProps = {
|
||||||
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
|
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
|
||||||
const parse = (draft: string) => draft || null;
|
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 } =
|
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
||||||
useEditableTextCell({
|
useEditableTextCell({
|
||||||
value,
|
value,
|
||||||
isEditing,
|
isEditing,
|
||||||
onCommit,
|
onCommit,
|
||||||
onCancel,
|
|
||||||
toDraft,
|
toDraft,
|
||||||
parse,
|
parse,
|
||||||
rowId,
|
rowId,
|
||||||
|
|
|
||||||
107
apps/client/src/ee/base/components/cells/cell-long-text.test.tsx
Normal file
107
apps/client/src/ee/base/components/cells/cell-long-text.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,6 +4,7 @@ import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
import { IBaseProperty } from "@/ee/base/types/base.types";
|
import { IBaseProperty } from "@/ee/base/types/base.types";
|
||||||
import { formatLongTextPreview } from "@/ee/base/formatters/cell-formatters";
|
import { formatLongTextPreview } from "@/ee/base/formatters/cell-formatters";
|
||||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||||
|
import { AUTOSAVE_DELAY_MS } from "@/ee/base/constants/autosave";
|
||||||
|
|
||||||
type CellLongTextProps = {
|
type CellLongTextProps = {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
|
|
@ -27,11 +28,9 @@ export function CellLongText({
|
||||||
isEditing,
|
isEditing,
|
||||||
onCommit,
|
onCommit,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
onCancel,
|
|
||||||
onTabNavigate,
|
onTabNavigate,
|
||||||
}: CellLongTextProps) {
|
}: CellLongTextProps) {
|
||||||
const [draft, setDraft] = useState(() => toText(value));
|
const [draft, setDraft] = useState(() => toText(value));
|
||||||
const cancelledRef = useRef(false);
|
|
||||||
const committedRef = useRef(false);
|
const committedRef = useRef(false);
|
||||||
const wasEditingRef = useRef(false);
|
const wasEditingRef = useRef(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
@ -40,7 +39,6 @@ export function CellLongText({
|
||||||
// value changes mid-edit so the user's typing is not clobbered.
|
// value changes mid-edit so the user's typing is not clobbered.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEditing && !wasEditingRef.current) {
|
if (isEditing && !wasEditingRef.current) {
|
||||||
cancelledRef.current = false;
|
|
||||||
committedRef.current = false;
|
committedRef.current = false;
|
||||||
setDraft(toText(value));
|
setDraft(toText(value));
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|
@ -53,11 +51,11 @@ export function CellLongText({
|
||||||
wasEditingRef.current = isEditing;
|
wasEditingRef.current = isEditing;
|
||||||
}, [isEditing, value]);
|
}, [isEditing, value]);
|
||||||
|
|
||||||
// Autosave after a typing pause; commit/cancel clear the pending fire so
|
// Autosave after a typing pause; commit clears the pending fire so a
|
||||||
// a closed editor can never write a stale or discarded draft.
|
// closed editor can never write a stale draft.
|
||||||
const debouncedAutosave = useDebouncedCallback(() => {
|
const debouncedAutosave = useDebouncedCallback(() => {
|
||||||
onValueChange(normalize(draft));
|
onValueChange(normalize(draft));
|
||||||
}, 10_000);
|
}, AUTOSAVE_DELAY_MS);
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (committedRef.current) return;
|
if (committedRef.current) return;
|
||||||
|
|
@ -65,11 +63,6 @@ export function CellLongText({
|
||||||
debouncedAutosave.cancel();
|
debouncedAutosave.cancel();
|
||||||
onCommit(normalize(draft));
|
onCommit(normalize(draft));
|
||||||
};
|
};
|
||||||
const cancel = () => {
|
|
||||||
cancelledRef.current = true;
|
|
||||||
debouncedAutosave.cancel();
|
|
||||||
onCancel();
|
|
||||||
};
|
|
||||||
|
|
||||||
const preview = formatLongTextPreview(toText(value));
|
const preview = formatLongTextPreview(toText(value));
|
||||||
|
|
||||||
|
|
@ -78,11 +71,6 @@ export function CellLongText({
|
||||||
opened={isEditing}
|
opened={isEditing}
|
||||||
onChange={(opened) => {
|
onChange={(opened) => {
|
||||||
if (opened) return;
|
if (opened) return;
|
||||||
// Programmatic close after cancel must not re-commit.
|
|
||||||
if (cancelledRef.current) {
|
|
||||||
cancelledRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
commit();
|
commit();
|
||||||
}}
|
}}
|
||||||
position="bottom-start"
|
position="bottom-start"
|
||||||
|
|
@ -135,7 +123,7 @@ export function CellLongText({
|
||||||
onTabNavigate?.(e.shiftKey);
|
onTabNavigate?.(e.shiftKey);
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
cancel();
|
commit();
|
||||||
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
commit();
|
commit();
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,6 @@ export function CellNumber({
|
||||||
rowId,
|
rowId,
|
||||||
isEditing,
|
isEditing,
|
||||||
onCommit,
|
onCommit,
|
||||||
onCancel,
|
|
||||||
}: CellNumberProps) {
|
}: CellNumberProps) {
|
||||||
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
|
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
|
||||||
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
||||||
|
|
@ -103,7 +102,6 @@ export function CellNumber({
|
||||||
value,
|
value,
|
||||||
isEditing,
|
isEditing,
|
||||||
onCommit,
|
onCommit,
|
||||||
onCancel,
|
|
||||||
toDraft,
|
toDraft,
|
||||||
parse: parseNumberDraft,
|
parse: parseNumberDraft,
|
||||||
rowId,
|
rowId,
|
||||||
|
|
|
||||||
45
apps/client/src/ee/base/components/cells/cell-text.test.tsx
Normal file
45
apps/client/src/ee/base/components/cells/cell-text.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -16,13 +16,12 @@ type CellTextProps = {
|
||||||
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
|
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
|
||||||
const parse = (draft: string) => draft;
|
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 } =
|
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
||||||
useEditableTextCell({
|
useEditableTextCell({
|
||||||
value,
|
value,
|
||||||
isEditing,
|
isEditing,
|
||||||
onCommit,
|
onCommit,
|
||||||
onCancel,
|
|
||||||
toDraft,
|
toDraft,
|
||||||
parse,
|
parse,
|
||||||
rowId,
|
rowId,
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,12 @@ type CellUrlProps = {
|
||||||
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
|
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
|
||||||
const parse = (draft: string) => draft || null;
|
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 } =
|
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
||||||
useEditableTextCell({
|
useEditableTextCell({
|
||||||
value,
|
value,
|
||||||
isEditing,
|
isEditing,
|
||||||
onCommit,
|
onCommit,
|
||||||
onCancel,
|
|
||||||
toDraft,
|
toDraft,
|
||||||
parse,
|
parse,
|
||||||
rowId,
|
rowId,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Textarea } from "@mantine/core";
|
import { Textarea } from "@mantine/core";
|
||||||
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
import { FieldProps, FieldShell } from "./detail-field";
|
import { FieldProps, FieldShell } from "./detail-field";
|
||||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
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 toText = (value: unknown) => (typeof value === "string" ? value : "");
|
||||||
const normalize = (s: string) => {
|
const normalize = (s: string) => {
|
||||||
|
|
@ -19,23 +21,21 @@ export function FieldLongText({
|
||||||
const text = toText(value);
|
const text = toText(value);
|
||||||
const [draft, setDraft] = useState(text);
|
const [draft, setDraft] = useState(text);
|
||||||
const [focused, setFocused] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!focused) setDraft(text);
|
if (!focused) setDraft(text);
|
||||||
}, [text, focused]);
|
}, [text, focused]);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (normalize(draft) !== normalize(text)) onChange(normalize(draft));
|
||||||
|
};
|
||||||
|
const autosave = useDebouncedCallback(save, AUTOSAVE_DELAY_MS);
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
setFocused(false);
|
setFocused(false);
|
||||||
onEditingChange?.(false);
|
onEditingChange?.(false);
|
||||||
if (cancelRef.current) {
|
autosave.cancel();
|
||||||
cancelRef.current = false;
|
save();
|
||||||
setDraft(text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (normalize(draft) !== normalize(text)) onChange(normalize(draft));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
|
|
@ -61,11 +61,13 @@ export function FieldLongText({
|
||||||
setFocused(true);
|
setFocused(true);
|
||||||
onEditingChange?.(true);
|
onEditingChange?.(true);
|
||||||
}}
|
}}
|
||||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
onChange={(e) => {
|
||||||
|
setDraft(e.currentTarget.value);
|
||||||
|
autosave();
|
||||||
|
}}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
cancelRef.current = true;
|
|
||||||
e.currentTarget.blur();
|
e.currentTarget.blur();
|
||||||
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NumberTypeOptions } from "@/ee/base/types/base.types";
|
import { NumberTypeOptions } from "@/ee/base/types/base.types";
|
||||||
import {
|
import {
|
||||||
formatNumber,
|
formatNumber,
|
||||||
|
|
@ -22,9 +22,6 @@ export function FieldNumber({
|
||||||
const numValue = typeof value === "number" ? value : null;
|
const numValue = typeof value === "number" ? value : null;
|
||||||
const [draft, setDraft] = useState(toDraft(value));
|
const [draft, setDraft] = useState(toDraft(value));
|
||||||
const [focused, setFocused] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!focused) setDraft(toDraft(value));
|
if (!focused) setDraft(toDraft(value));
|
||||||
|
|
@ -43,11 +40,6 @@ export function FieldNumber({
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
setFocused(false);
|
setFocused(false);
|
||||||
onEditingChange?.(false);
|
onEditingChange?.(false);
|
||||||
if (cancelRef.current) {
|
|
||||||
cancelRef.current = false;
|
|
||||||
setDraft(toDraft(value));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (parseNumberDraft(draft) !== numValue) onChange(parseNumberDraft(draft));
|
if (parseNumberDraft(draft) !== numValue) onChange(parseNumberDraft(draft));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -86,7 +78,6 @@ export function FieldNumber({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.currentTarget.blur();
|
e.currentTarget.blur();
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
cancelRef.current = true;
|
|
||||||
e.currentTarget.blur();
|
e.currentTarget.blur();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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 { IconExternalLink, IconMail } from "@tabler/icons-react";
|
||||||
import { FieldProps, FieldShell } from "./detail-field";
|
import { FieldProps, FieldShell } from "./detail-field";
|
||||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
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 toText = (value: unknown) => (typeof value === "string" ? value : "");
|
||||||
|
|
||||||
|
|
@ -15,24 +17,22 @@ export function FieldText({
|
||||||
const text = toText(value);
|
const text = toText(value);
|
||||||
const [draft, setDraft] = useState(text);
|
const [draft, setDraft] = useState(text);
|
||||||
const [focused, setFocused] = useState(false);
|
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.
|
// Track remote/navigation updates while not typing.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focused) setDraft(text);
|
if (!focused) setDraft(text);
|
||||||
}, [text, focused]);
|
}, [text, focused]);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (draft !== text) onChange(draft);
|
||||||
|
};
|
||||||
|
const autosave = useDebouncedCallback(save, AUTOSAVE_DELAY_MS);
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
setFocused(false);
|
setFocused(false);
|
||||||
onEditingChange?.(false);
|
onEditingChange?.(false);
|
||||||
if (cancelRef.current) {
|
autosave.cancel();
|
||||||
cancelRef.current = false;
|
save();
|
||||||
setDraft(text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (draft !== text) onChange(draft);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
|
|
@ -65,14 +65,16 @@ export function FieldText({
|
||||||
setFocused(true);
|
setFocused(true);
|
||||||
onEditingChange?.(true);
|
onEditingChange?.(true);
|
||||||
}}
|
}}
|
||||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
onChange={(e) => {
|
||||||
|
setDraft(e.currentTarget.value);
|
||||||
|
autosave();
|
||||||
|
}}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.currentTarget.blur();
|
e.currentTarget.blur();
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
cancelRef.current = true;
|
|
||||||
e.currentTarget.blur();
|
e.currentTarget.blur();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -168,10 +168,11 @@ export function RowDetailModal({
|
||||||
|
|
||||||
// Mantine's closeOnEscape runs a capture-phase window listener that fires
|
// Mantine's closeOnEscape runs a capture-phase window listener that fires
|
||||||
// before inner popovers and inputs see the key, so we manage Esc ourselves
|
// before inner popovers and inputs see the key, so we manage Esc ourselves
|
||||||
// and yield to: nested dialogs (delete confirm), open popovers
|
// and yield to nested dialogs (delete confirm) and open popovers
|
||||||
// ([data-position]) and editable elements. Arrows step records under the
|
// ([data-position]). Esc in an inline field blurs it first, which saves the
|
||||||
// same yield rules. Mantine puts role="dialog" and our content class on
|
// edit, then closes; arrows stay with the field. Mantine puts role="dialog"
|
||||||
// the same element, which distinguishes this modal from nested ones.
|
// and our content class on the same element, which distinguishes this
|
||||||
|
// modal from nested ones.
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
const isEscape = event.key === "Escape";
|
const isEscape = event.key === "Escape";
|
||||||
|
|
@ -183,11 +184,10 @@ export function RowDetailModal({
|
||||||
if (dialog && !dialog.classList.contains(classes.modalContent)) {
|
if (dialog && !dialog.classList.contains(classes.modalContent)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (target.closest("[data-position]")) return;
|
||||||
target.closest("[data-position]") ||
|
if (target.matches("input, textarea, select, [contenteditable='true']")) {
|
||||||
target.matches("input, textarea, select, [contenteditable='true']")
|
if (!isEscape) return;
|
||||||
) {
|
target.blur();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isEscape) {
|
if (isEscape) {
|
||||||
|
|
@ -383,7 +383,7 @@ export function RowDetailModal({
|
||||||
|
|
||||||
<span className={classes.kbdGroup}>
|
<span className={classes.kbdGroup}>
|
||||||
<kbd className={classes.kbd}>Esc</kbd>
|
<kbd className={classes.kbd}>Esc</kbd>
|
||||||
<span>{t("to reset")}</span>
|
<span>{t("to close")}</span>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
||||||
import { timeAgo } from "@/lib/time.ts";
|
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";
|
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||||
|
|
||||||
type RowDetailTitleProps = {
|
type RowDetailTitleProps = {
|
||||||
|
|
@ -24,21 +26,23 @@ export function RowDetailTitle({
|
||||||
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
|
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
|
||||||
: "";
|
: "";
|
||||||
const [value, setValue] = useState(initial);
|
const [value, setValue] = useState(initial);
|
||||||
const cancelRef = useRef(false);
|
const focusedRef = useRef(false);
|
||||||
|
|
||||||
const commit = () => {
|
const save = () => {
|
||||||
onEditingChange?.(false);
|
|
||||||
if (cancelRef.current) {
|
|
||||||
cancelRef.current = false;
|
|
||||||
setValue(initial);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (value !== initial) onCommit(value);
|
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(() => {
|
useEffect(() => {
|
||||||
setValue(initial);
|
if (!focusedRef.current) setValue(initial);
|
||||||
}, [initial]);
|
}, [initial]);
|
||||||
|
|
||||||
const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : "";
|
const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : "";
|
||||||
|
|
@ -55,13 +59,16 @@ export function RowDetailTitle({
|
||||||
value={value}
|
value={value}
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
|
focusedRef.current = true;
|
||||||
onEditingChange?.(true);
|
onEditingChange?.(true);
|
||||||
}}
|
}}
|
||||||
onChange={(e) => setValue(e.currentTarget.value)}
|
onChange={(e) => {
|
||||||
|
setValue(e.currentTarget.value);
|
||||||
|
autosave();
|
||||||
|
}}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
cancelRef.current = true;
|
|
||||||
e.currentTarget.blur();
|
e.currentTarget.blur();
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
||||||
2
apps/client/src/ee/base/constants/autosave.ts
Normal file
2
apps/client/src/ee/base/constants/autosave.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Typing pause after which a text edit saves itself.
|
||||||
|
export const AUTOSAVE_DELAY_MS = 1000;
|
||||||
|
|
@ -6,7 +6,6 @@ export type UseEditableTextCellParams = {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
onCommit: (value: unknown) => void;
|
onCommit: (value: unknown) => void;
|
||||||
onCancel: () => void;
|
|
||||||
/** value -> the draft string shown in the input when editing begins */
|
/** value -> the draft string shown in the input when editing begins */
|
||||||
toDraft: (value: unknown) => string;
|
toDraft: (value: unknown) => string;
|
||||||
/** draft string -> the value passed to onCommit */
|
/** draft string -> the value passed to onCommit */
|
||||||
|
|
@ -27,7 +26,6 @@ export function useEditableTextCell({
|
||||||
value,
|
value,
|
||||||
isEditing,
|
isEditing,
|
||||||
onCommit,
|
onCommit,
|
||||||
onCancel,
|
|
||||||
toDraft,
|
toDraft,
|
||||||
parse,
|
parse,
|
||||||
rowId,
|
rowId,
|
||||||
|
|
@ -85,11 +83,10 @@ export function useEditableTextCell({
|
||||||
commitOnce(parse(draft));
|
commitOnce(parse(draft));
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
committedRef.current = true;
|
commitOnce(parse(draft));
|
||||||
onCancel();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[draft, parse, commitOnce, onCancel],
|
[draft, parse, commitOnce],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
|
|
|
||||||
56
apps/client/src/ee/base/hooks/use-grid-keyboard-nav.test.ts
Normal file
56
apps/client/src/ee/base/hooks/use-grid-keyboard-nav.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -171,6 +171,8 @@ export function useGridKeyboardNav({
|
||||||
}
|
}
|
||||||
case "Escape": {
|
case "Escape": {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// Blurring saves the draft, as on Enter.
|
||||||
|
(document.activeElement as HTMLElement | null)?.blur();
|
||||||
setEditingCell(null);
|
setEditingCell(null);
|
||||||
setFocusedCell(editingCell);
|
setFocusedCell(editingCell);
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
.row {
|
.row {
|
||||||
position: relative;
|
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);
|
border-radius: var(--mantine-radius-sm);
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
@mixin hover {
|
@mixin hover {
|
||||||
background-color: light-dark(
|
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 {
|
.icon {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|
|
||||||
|
|
@ -196,41 +196,39 @@ export default function TemplatePickerModal({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((tpl) => (
|
filtered.map((tpl) => (
|
||||||
<UnstyledButton
|
<div key={tpl.id} className={classes.row}>
|
||||||
key={tpl.id}
|
<UnstyledButton
|
||||||
className={classes.row}
|
className={classes.pickButton}
|
||||||
onClick={() => handlePick(tpl)}
|
onClick={() => handlePick(tpl)}
|
||||||
>
|
>
|
||||||
<div className={classes.icon}>
|
<div className={classes.icon}>
|
||||||
{tpl.icon ? (
|
{tpl.icon ? (
|
||||||
<span>{tpl.icon}</span>
|
<span>{tpl.icon}</span>
|
||||||
) : (
|
) : (
|
||||||
<IconFileText
|
<IconFileText
|
||||||
size={16}
|
size={16}
|
||||||
color="var(--mantine-color-gray-6)"
|
color="var(--mantine-color-gray-6)"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.title}>{tpl.title}</div>
|
<div className={classes.title}>{tpl.title}</div>
|
||||||
<div className={classes.scope}>
|
<div className={classes.scope}>
|
||||||
{tpl.spaceId
|
{tpl.spaceId
|
||||||
? spaceNamesById.get(tpl.spaceId) ?? t("Space")
|
? spaceNamesById.get(tpl.spaceId) ?? t("Space")
|
||||||
: t("Global")}
|
: t("Global")}
|
||||||
</div>
|
</div>
|
||||||
|
</UnstyledButton>
|
||||||
<Button
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
className={classes.useButton}
|
className={classes.useButton}
|
||||||
loading={useTemplateMutation.isPending}
|
loading={useTemplateMutation.isPending}
|
||||||
disabled={useTemplateMutation.isPending}
|
disabled={useTemplateMutation.isPending}
|
||||||
onClick={(e) => {
|
onClick={() => handleQuickUse(tpl)}
|
||||||
e.stopPropagation();
|
|
||||||
handleQuickUse(tpl);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("Use")}
|
{t("Use")}
|
||||||
</Button>
|
</Button>
|
||||||
</UnstyledButton>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,12 @@ import { useBaseQuery } from "@/ee/base/queries/base-query";
|
||||||
import { pinOffsetWatcher } from "@docmost/editor-ext";
|
import { pinOffsetWatcher } from "@docmost/editor-ext";
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
import { Feature } from "@/ee/features";
|
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 { 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";
|
import classes from "./base-embed.module.css";
|
||||||
|
|
||||||
const SIDE_GUTTER = 8;
|
const SIDE_GUTTER = 8;
|
||||||
|
|
@ -63,6 +67,14 @@ export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) {
|
||||||
pendingKey ? "" : pageId ?? "",
|
pendingKey ? "" : pageId ?? "",
|
||||||
);
|
);
|
||||||
const { data: page } = usePageQuery({ pageId: pageId ?? undefined });
|
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(() => {
|
useEffect(() => {
|
||||||
const wrapper = wrapperRef.current;
|
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.
|
// mounts) is reserved only for the skeleton/loading/table states.
|
||||||
const isCompact = !pendingKey && (!pageId || isError);
|
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;
|
let content: React.ReactNode;
|
||||||
if (pendingKey) {
|
if (pendingKey) {
|
||||||
|
|
@ -159,6 +172,13 @@ export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) {
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconLink size={14} />}
|
||||||
|
disabled={!page}
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
>
|
||||||
|
{t("Copy link")}
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconX size={14} />}
|
leftSection={<IconX size={14} />}
|
||||||
onClick={() => deleteNode()}
|
onClick={() => deleteNode()}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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" }] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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
|
// we have to do this validation here to allow the default link extension to takeover if needs be
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const url = clipboardData.trim();
|
const url = pageLink.trim();
|
||||||
const { from: pos, empty } = editor.state.selection;
|
const { from: pos, empty } = editor.state.selection;
|
||||||
const match = INTERNAL_LINK_REGEX.exec(url);
|
const match = INTERNAL_LINK_REGEX.exec(url);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,3 +27,23 @@
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ export function EmptyPageGetStarted({
|
||||||
key: "base",
|
key: "base",
|
||||||
label: t("Base"),
|
label: t("Base"),
|
||||||
icon: IconTable,
|
icon: IconTable,
|
||||||
|
color: "blue",
|
||||||
onClick: () => convertMutation.mutate({ pageId }),
|
onClick: () => convertMutation.mutate({ pageId }),
|
||||||
disabled: convertMutation.isPending,
|
disabled: convertMutation.isPending,
|
||||||
},
|
},
|
||||||
|
|
@ -106,6 +107,7 @@ export function EmptyPageGetStarted({
|
||||||
key: "kanban",
|
key: "kanban",
|
||||||
label: t("Kanban"),
|
label: t("Kanban"),
|
||||||
icon: IconLayoutKanban,
|
icon: IconLayoutKanban,
|
||||||
|
color: "violet",
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
convertMutation.mutate({ pageId, template: "kanban" }),
|
convertMutation.mutate({ pageId, template: "kanban" }),
|
||||||
disabled: convertMutation.isPending,
|
disabled: convertMutation.isPending,
|
||||||
|
|
@ -118,6 +120,7 @@ export function EmptyPageGetStarted({
|
||||||
key: "template",
|
key: "template",
|
||||||
label: t("Template"),
|
label: t("Template"),
|
||||||
icon: IconTemplate,
|
icon: IconTemplate,
|
||||||
|
color: "pink",
|
||||||
onClick: openTemplatePicker,
|
onClick: openTemplatePicker,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
|
|
@ -137,10 +140,17 @@ export function EmptyPageGetStarted({
|
||||||
{chips.map((chip) => (
|
{chips.map((chip) => (
|
||||||
<Button
|
<Button
|
||||||
key={chip.key}
|
key={chip.key}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="xs"
|
color="gray"
|
||||||
radius="xl"
|
size="sm"
|
||||||
leftSection={<chip.icon size={16} />}
|
radius="md"
|
||||||
|
className={classes.chip}
|
||||||
|
leftSection={
|
||||||
|
<chip.icon
|
||||||
|
size={18}
|
||||||
|
color={`var(--mantine-color-${chip.color}-6)`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
onClick={chip.onClick}
|
onClick={chip.onClick}
|
||||||
disabled={chip.disabled}
|
disabled={chip.disabled}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
IconLayoutKanban,
|
IconLayoutKanban,
|
||||||
|
IconLayoutNavbar,
|
||||||
IconMath,
|
IconMath,
|
||||||
IconMathFunction,
|
IconMathFunction,
|
||||||
IconRotate2,
|
IconRotate2,
|
||||||
|
|
@ -87,6 +88,13 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
|
||||||
>
|
>
|
||||||
{t("Toggle block")}
|
{t("Toggle block")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconLayoutNavbar size={16} />}
|
||||||
|
disabled={!editor.can().insertTabs()}
|
||||||
|
onClick={() => editor.chain().focus().insertTabs().run()}
|
||||||
|
>
|
||||||
|
{t("Tabs")}
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconTag size={16} />}
|
leftSection={<IconTag size={16} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { getMarkRange, type Editor } from "@tiptap/core";
|
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 { NodeSelection } from "@tiptap/pm/state";
|
||||||
|
import { getBlockPlacement } from "@/features/editor/utils/block-placement";
|
||||||
|
|
||||||
export type IntegrationDisplay = "card" | "mention" | "url";
|
export type IntegrationDisplay = "card" | "mention" | "url";
|
||||||
|
|
||||||
|
|
@ -66,74 +67,13 @@ function getCardPlacement(
|
||||||
range: null;
|
range: null;
|
||||||
content: 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;
|
const cardType = editor.state.schema.nodes.integrationCard;
|
||||||
if (!cardType) {
|
const placement = cardType
|
||||||
return { canPlace: false, range: null, content: null };
|
? getBlockPlacement(editor.state.doc, from, to, cardType.create(attrs))
|
||||||
}
|
: null;
|
||||||
|
return placement
|
||||||
const before = trimInlineBoundary(
|
? { canPlace: true, ...placement }
|
||||||
$from.parent.content.cut(0, from - parentStart),
|
: { canPlace: false, range: null, content: null };
|
||||||
"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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceWithCard(
|
function replaceWithCard(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { getPageById } from "@/features/page/services/page-service.ts";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import { v7 } from "uuid";
|
import { v7 } from "uuid";
|
||||||
import { extractPageSlugId } from "@/lib";
|
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 = (
|
export type LinkFn = (
|
||||||
url: string,
|
url: string,
|
||||||
|
|
@ -42,6 +44,9 @@ export const handleInternalLink =
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
const transaction = view.state.tr.replaceWith(pos, pos, node);
|
const transaction = view.state.tr.replaceWith(pos, pos, node);
|
||||||
|
if (page.isBase && canConvertMentionToBaseEmbed(transaction.doc, pos)) {
|
||||||
|
transaction.setMeta(basePasteMenuKey, { pos });
|
||||||
|
}
|
||||||
view.dispatch(transaction);
|
view.dispatch(transaction);
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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 IntegrationCardView from "@/features/editor/components/integration-link/integration-card-view.tsx";
|
||||||
import IntegrationMentionView from "@/features/editor/components/integration-link/integration-mention-view.tsx";
|
import IntegrationMentionView from "@/features/editor/components/integration-link/integration-mention-view.tsx";
|
||||||
import { IntegrationPasteMenuExtension } from "@/features/editor/extensions/integration-paste-menu";
|
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 TabsView from "@/features/editor/components/tabs/tabs-view.tsx";
|
||||||
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
|
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
|
||||||
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
|
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
|
||||||
|
|
@ -408,6 +409,7 @@ export const mainExtensions = [
|
||||||
view: IntegrationMentionView,
|
view: IntegrationMentionView,
|
||||||
}),
|
}),
|
||||||
IntegrationPasteMenuExtension,
|
IntegrationPasteMenuExtension,
|
||||||
|
BasePasteMenuExtension,
|
||||||
Status.configure({
|
Status.configure({
|
||||||
view: StatusView,
|
view: StatusView,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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 { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||||
import { IntegrationPasteMenu } from "@/features/editor/components/integration-link/integration-paste-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 { getInstalledIntegrations } from "@/features/integration/services/integration-service";
|
||||||
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
@ -527,6 +528,7 @@ function CollabPageEditor({
|
||||||
<DrawioMenu editor={editor} />
|
<DrawioMenu editor={editor} />
|
||||||
<ColumnsMenu editor={editor} />
|
<ColumnsMenu editor={editor} />
|
||||||
<IntegrationPasteMenu editor={editor} />
|
<IntegrationPasteMenu editor={editor} />
|
||||||
|
<BasePasteMenu editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{editor && !editorIsEditable && (editable || canComment) && (
|
{editor && !editorIsEditable && (editable || canComment) && (
|
||||||
|
|
|
||||||
74
apps/client/src/features/editor/utils/block-placement.ts
Normal file
74
apps/client/src/features/editor/utils/block-placement.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -52,13 +52,21 @@ export function usePageQuery(
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.data) {
|
const cached = queryClient.getQueryState<IPage>([
|
||||||
if (isValidUuid(pageInput.pageId)) {
|
"pages",
|
||||||
queryClient.setQueryData(["pages", query.data.slugId], query.data);
|
pageInput.pageId,
|
||||||
} else {
|
]);
|
||||||
queryClient.setQueryData(["pages", query.data.id], query.data);
|
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]);
|
}, [query.data]);
|
||||||
|
|
||||||
return query;
|
return query;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.96.0",
|
"version": "0.97.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|
@ -82,7 +82,7 @@
|
||||||
"csv-stringify": "6.8.0",
|
"csv-stringify": "6.8.0",
|
||||||
"fast-bm25": "0.0.5",
|
"fast-bm25": "0.0.5",
|
||||||
"fs-extra": "11.3.4",
|
"fs-extra": "11.3.4",
|
||||||
"happy-dom": "20.8.9",
|
"happy-dom": "21.0.4",
|
||||||
"ioredis": "5.10.1",
|
"ioredis": "5.10.1",
|
||||||
"js-tiktoken": "1.0.21",
|
"js-tiktoken": "1.0.21",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
|
|
@ -90,14 +90,14 @@
|
||||||
"kysely-migration-cli": "0.4.2",
|
"kysely-migration-cli": "0.4.2",
|
||||||
"kysely-postgres-js": "3.0.0",
|
"kysely-postgres-js": "3.0.0",
|
||||||
"ldapts": "8.1.7",
|
"ldapts": "8.1.7",
|
||||||
"mammoth": "1.12.0",
|
"mammoth": "1.13.0",
|
||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"msgpackr": "1.11.9",
|
"msgpackr": "1.11.9",
|
||||||
"nanoid": "5.1.16",
|
"nanoid": "5.1.16",
|
||||||
"nestjs-cls": "6.2.0",
|
"nestjs-cls": "6.2.0",
|
||||||
"nestjs-kysely": "3.1.2",
|
"nestjs-kysely": "3.1.2",
|
||||||
"nestjs-pino": "4.6.1",
|
"nestjs-pino": "4.6.1",
|
||||||
"nodemailer": "10.0.10",
|
"nodemailer": "10.1.7",
|
||||||
"openid-client": "6.8.2",
|
"openid-client": "6.8.2",
|
||||||
"otpauth": "9.5.0",
|
"otpauth": "9.5.0",
|
||||||
"p-limit": "7.3.0",
|
"p-limit": "7.3.0",
|
||||||
|
|
@ -120,10 +120,10 @@
|
||||||
"tlds": "1.261.0",
|
"tlds": "1.261.0",
|
||||||
"tmp-promise": "3.0.3",
|
"tmp-promise": "3.0.3",
|
||||||
"typesense": "3.0.5",
|
"typesense": "3.0.5",
|
||||||
"undici": "7.29.1",
|
"undici": "7.30.1",
|
||||||
"ws": "8.21.3",
|
"ws": "8.21.3",
|
||||||
"yauzl": "3.4.0",
|
"yauzl": "3.4.0",
|
||||||
"zod": "4.3.6"
|
"zod": "4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.28.0",
|
"@eslint/js": "^9.28.0",
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,17 @@ import { IntegrationRepo } from './repos/integration.repo';
|
||||||
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
|
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
|
||||||
import { IntegrationListener } from './integration.listener';
|
import { IntegrationListener } from './integration.listener';
|
||||||
import { IntegrationProcessor } from './integration.processor';
|
import { IntegrationProcessor } from './integration.processor';
|
||||||
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [IntegrationController, OAuthController, UnfurlController],
|
controllers: [IntegrationController, OAuthController, UnfurlController],
|
||||||
providers: [
|
providers: [
|
||||||
IntegrationRegistry,
|
{
|
||||||
|
provide: IntegrationRegistry,
|
||||||
|
useFactory: (environmentService: EnvironmentService) =>
|
||||||
|
new IntegrationRegistry(environmentService.isCloud()),
|
||||||
|
inject: [EnvironmentService],
|
||||||
|
},
|
||||||
IntegrationService,
|
IntegrationService,
|
||||||
IntegrationConnectionService,
|
IntegrationConnectionService,
|
||||||
OAuthService,
|
OAuthService,
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ describe('GitHubProvider host matching', () => {
|
||||||
const { provider, unfurlPullRequest } = buildProvider();
|
const { provider, unfurlPullRequest } = buildProvider();
|
||||||
await unfurlWith(provider, PUBLIC_PR);
|
await unfurlWith(provider, PUBLIC_PR);
|
||||||
expect(unfurlPullRequest).toHaveBeenCalledWith(
|
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();
|
const { provider, unfurlPullRequest } = buildProvider();
|
||||||
await unfurlWith(provider, GHE_PR);
|
await unfurlWith(provider, GHE_PR);
|
||||||
expect(unfurlPullRequest).toHaveBeenCalledWith(
|
expect(unfurlPullRequest).toHaveBeenCalledWith(
|
||||||
'token', `${GHE_BASE}/api/v3`, 'o', 'r', 1, GHE_PR,
|
'Bearer token', `${GHE_BASE}/api/v3`, 'o', 'r', 1, GHE_PR,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
LinkDescription,
|
LinkDescription,
|
||||||
OAuthConfig,
|
OAuthConfig,
|
||||||
ProviderAccount,
|
ProviderAccount,
|
||||||
|
PublicUnfurlOpts,
|
||||||
ResolveAccountOpts,
|
ResolveAccountOpts,
|
||||||
UnfurlPattern,
|
UnfurlPattern,
|
||||||
UnfurlOpts,
|
UnfurlOpts,
|
||||||
|
|
@ -13,6 +14,7 @@ import {
|
||||||
import { GitHubService } from './github.service';
|
import { GitHubService } from './github.service';
|
||||||
import { buildGitHubPatterns } from './github-patterns';
|
import { buildGitHubPatterns } from './github-patterns';
|
||||||
import { normalizeBaseUrl } from '../../utils/integration.utils';
|
import { normalizeBaseUrl } from '../../utils/integration.utils';
|
||||||
|
import { credentialEnvKey } from '../../oauth/credential-env';
|
||||||
|
|
||||||
const DEFAULT_BASE_URL = 'https://github.com';
|
const DEFAULT_BASE_URL = 'https://github.com';
|
||||||
|
|
||||||
|
|
@ -63,13 +65,35 @@ export class GitHubProvider extends IntegrationProvider {
|
||||||
|
|
||||||
async resolveAccount(opts: ResolveAccountOpts): Promise<ProviderAccount> {
|
async resolveAccount(opts: ResolveAccountOpts): Promise<ProviderAccount> {
|
||||||
return this.githubService.getAccount(
|
return this.githubService.getAccount(
|
||||||
opts.accessToken,
|
`Bearer ${opts.accessToken}`,
|
||||||
this.resolveApiBaseUrl(),
|
this.resolveApiBaseUrl(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async unfurl(opts: UnfurlOpts): Promise<UnfurlResult> {
|
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 apiBaseUrl = this.resolveApiBaseUrl();
|
||||||
const owner = match[1];
|
const owner = match[1];
|
||||||
const repo = match[2];
|
const repo = match[2];
|
||||||
|
|
@ -78,33 +102,33 @@ export class GitHubProvider extends IntegrationProvider {
|
||||||
case 'github-pr': {
|
case 'github-pr': {
|
||||||
const number = parseInt(match[3], 10);
|
const number = parseInt(match[3], 10);
|
||||||
return this.githubService.unfurlPullRequest(
|
return this.githubService.unfurlPullRequest(
|
||||||
accessToken, apiBaseUrl, owner, repo, number, url,
|
authorization, apiBaseUrl, owner, repo, number, url,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'github-issue': {
|
case 'github-issue': {
|
||||||
const number = parseInt(match[3], 10);
|
const number = parseInt(match[3], 10);
|
||||||
return this.githubService.unfurlIssue(
|
return this.githubService.unfurlIssue(
|
||||||
accessToken, apiBaseUrl, owner, repo, number, url,
|
authorization, apiBaseUrl, owner, repo, number, url,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'github-repo':
|
case 'github-repo':
|
||||||
return this.githubService.unfurlRepo(
|
return this.githubService.unfurlRepo(
|
||||||
accessToken, apiBaseUrl, owner, repo, url,
|
authorization, apiBaseUrl, owner, repo, url,
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'github-commit': {
|
case 'github-commit': {
|
||||||
const sha = match[3];
|
const sha = match[3];
|
||||||
return this.githubService.unfurlCommit(
|
return this.githubService.unfurlCommit(
|
||||||
accessToken, apiBaseUrl, owner, repo, sha, url,
|
authorization, apiBaseUrl, owner, repo, sha, url,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'github-pr-commit': {
|
case 'github-pr-commit': {
|
||||||
const sha = match[4];
|
const sha = match[4];
|
||||||
return this.githubService.unfurlCommit(
|
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-issues-list':
|
||||||
case 'github-releases-list':
|
case 'github-releases-list':
|
||||||
return this.githubService.unfurlCollectionPage(
|
return this.githubService.unfurlCollectionPage(
|
||||||
accessToken, apiBaseUrl, owner, repo, patternType.replace('github-', ''), url,
|
authorization, apiBaseUrl, owner, repo, patternType.replace('github-', ''), url,
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,11 @@ const BODY = {
|
||||||
const service = new GitHubService();
|
const service = new GitHubService();
|
||||||
|
|
||||||
const REQUESTS: [string, (api: string, owner: string, repo: string) => Promise<unknown>, string][] = [
|
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'],
|
['pull request', (api, owner, repo) => service.unfurlPullRequest('Bearer token', api, owner, repo, 1, PAGE), '/pulls/1'],
|
||||||
['issue', (api, owner, repo) => service.unfurlIssue('token', api, owner, repo, 2, PAGE), '/issues/2'],
|
['issue', (api, owner, repo) => service.unfurlIssue('Bearer token', api, owner, repo, 2, PAGE), '/issues/2'],
|
||||||
['repo', (api, owner, repo) => service.unfurlRepo('token', api, owner, repo, PAGE), ''],
|
['repo', (api, owner, repo) => service.unfurlRepo('Bearer token', api, owner, repo, PAGE), ''],
|
||||||
['commit', (api, owner, repo) => service.unfurlCommit('token', api, owner, repo, 'abc1234', PAGE), '/commits/abc1234'],
|
['commit', (api, owner, repo) => service.unfurlCommit('Bearer token', api, owner, repo, 'abc1234', PAGE), '/commits/abc1234'],
|
||||||
['collection page', (api, owner, repo) => service.unfurlCollectionPage('token', api, owner, repo, 'pulls-list', PAGE), ''],
|
['collection page', (api, owner, repo) => service.unfurlCollectionPage('Bearer token', api, owner, repo, 'pulls-list', PAGE), ''],
|
||||||
];
|
];
|
||||||
|
|
||||||
function requestedUrls(): string[] {
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@ import { repoApiPath } from './github.utils';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GitHubService {
|
export class GitHubService {
|
||||||
async getAccount(
|
async getAccount(
|
||||||
accessToken: string,
|
authorization: string,
|
||||||
apiBaseUrl: string,
|
apiBaseUrl: string,
|
||||||
): Promise<ProviderAccount> {
|
): Promise<ProviderAccount> {
|
||||||
const data = await this.apiGet(accessToken, apiBaseUrl, '/user');
|
const data = await this.apiGet(authorization, apiBaseUrl, '/user');
|
||||||
return toProviderAccount({
|
return toProviderAccount({
|
||||||
id: data.id,
|
id: data.id,
|
||||||
displayName: data.name,
|
displayName: data.name,
|
||||||
|
|
@ -23,7 +23,7 @@ export class GitHubService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async unfurlPullRequest(
|
async unfurlPullRequest(
|
||||||
accessToken: string,
|
authorization: string,
|
||||||
apiBaseUrl: string,
|
apiBaseUrl: string,
|
||||||
owner: string,
|
owner: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
|
|
@ -31,7 +31,7 @@ export class GitHubService {
|
||||||
url: string,
|
url: string,
|
||||||
): Promise<UnfurlResult> {
|
): Promise<UnfurlResult> {
|
||||||
const data = await this.apiGet(
|
const data = await this.apiGet(
|
||||||
accessToken,
|
authorization,
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
`${repoApiPath(owner, repo)}/pulls/${number}`,
|
`${repoApiPath(owner, repo)}/pulls/${number}`,
|
||||||
);
|
);
|
||||||
|
|
@ -66,7 +66,7 @@ export class GitHubService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async unfurlIssue(
|
async unfurlIssue(
|
||||||
accessToken: string,
|
authorization: string,
|
||||||
apiBaseUrl: string,
|
apiBaseUrl: string,
|
||||||
owner: string,
|
owner: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
|
|
@ -74,7 +74,7 @@ export class GitHubService {
|
||||||
url: string,
|
url: string,
|
||||||
): Promise<UnfurlResult> {
|
): Promise<UnfurlResult> {
|
||||||
const data = await this.apiGet(
|
const data = await this.apiGet(
|
||||||
accessToken,
|
authorization,
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
`${repoApiPath(owner, repo)}/issues/${number}`,
|
`${repoApiPath(owner, repo)}/issues/${number}`,
|
||||||
);
|
);
|
||||||
|
|
@ -107,14 +107,14 @@ export class GitHubService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async unfurlRepo(
|
async unfurlRepo(
|
||||||
accessToken: string,
|
authorization: string,
|
||||||
apiBaseUrl: string,
|
apiBaseUrl: string,
|
||||||
owner: string,
|
owner: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
url: string,
|
url: string,
|
||||||
): Promise<UnfurlResult> {
|
): Promise<UnfurlResult> {
|
||||||
const data = await this.apiGet(
|
const data = await this.apiGet(
|
||||||
accessToken,
|
authorization,
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
repoApiPath(owner, repo),
|
repoApiPath(owner, repo),
|
||||||
);
|
);
|
||||||
|
|
@ -143,7 +143,7 @@ export class GitHubService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async unfurlCommit(
|
async unfurlCommit(
|
||||||
accessToken: string,
|
authorization: string,
|
||||||
apiBaseUrl: string,
|
apiBaseUrl: string,
|
||||||
owner: string,
|
owner: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
|
|
@ -151,7 +151,7 @@ export class GitHubService {
|
||||||
url: string,
|
url: string,
|
||||||
): Promise<UnfurlResult> {
|
): Promise<UnfurlResult> {
|
||||||
const data = await this.apiGet(
|
const data = await this.apiGet(
|
||||||
accessToken,
|
authorization,
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
`${repoApiPath(owner, repo)}/commits/${sha}`,
|
`${repoApiPath(owner, repo)}/commits/${sha}`,
|
||||||
);
|
);
|
||||||
|
|
@ -217,7 +217,7 @@ export class GitHubService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async unfurlCollectionPage(
|
async unfurlCollectionPage(
|
||||||
accessToken: string,
|
authorization: string,
|
||||||
apiBaseUrl: string,
|
apiBaseUrl: string,
|
||||||
owner: string,
|
owner: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
|
|
@ -225,7 +225,7 @@ export class GitHubService {
|
||||||
url: string,
|
url: string,
|
||||||
): Promise<UnfurlResult> {
|
): Promise<UnfurlResult> {
|
||||||
const data = await this.apiGet(
|
const data = await this.apiGet(
|
||||||
accessToken,
|
authorization,
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
repoApiPath(owner, repo),
|
repoApiPath(owner, repo),
|
||||||
);
|
);
|
||||||
|
|
@ -265,13 +265,13 @@ export class GitHubService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async apiGet(
|
private async apiGet(
|
||||||
accessToken: string,
|
authorization: string,
|
||||||
apiBaseUrl: string,
|
apiBaseUrl: string,
|
||||||
path: string,
|
path: string,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const response = await providerApiFetch('GitHub', `${apiBaseUrl}${path}`, {
|
const response = await providerApiFetch('GitHub', `${apiBaseUrl}${path}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: authorization,
|
||||||
Accept: 'application/vnd.github.v3+json',
|
Accept: 'application/vnd.github.v3+json',
|
||||||
'User-Agent': 'Docmost',
|
'User-Agent': 'Docmost',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@ export type UnfurlOpts = {
|
||||||
integrationId: string;
|
integrationId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PublicUnfurlOpts = Pick<UnfurlOpts, 'url' | 'match' | 'patternType'>;
|
||||||
|
|
||||||
// UnfurlService shows no card for it instead of logging an error.
|
// UnfurlService shows no card for it instead of logging an error.
|
||||||
export class UnfurlForbiddenError extends Error {
|
export class UnfurlForbiddenError extends Error {
|
||||||
constructor(message = 'Not authorized to unfurl this link') {
|
constructor(message = 'Not authorized to unfurl this link') {
|
||||||
|
|
@ -221,6 +223,9 @@ export abstract class IntegrationProvider {
|
||||||
|
|
||||||
unfurl?(opts: UnfurlOpts): Promise<UnfurlResult>;
|
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".
|
// Tokenless summary for the connect prompt, e.g. "Pull Request #13337".
|
||||||
describeLink?(
|
describeLink?(
|
||||||
patternType: string,
|
patternType: string,
|
||||||
|
|
|
||||||
|
|
@ -22,4 +22,30 @@ describe('IntegrationRegistry', () => {
|
||||||
'Integration provider "linear" is already registered',
|
'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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,23 @@ import {
|
||||||
IntegrationProvider,
|
IntegrationProvider,
|
||||||
} from './integration-provider.interface';
|
} 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()
|
@Injectable()
|
||||||
export class IntegrationRegistry {
|
export class IntegrationRegistry {
|
||||||
private providers = new Map<string, IntegrationProvider>();
|
private providers = new Map<string, IntegrationProvider>();
|
||||||
|
|
||||||
|
constructor(private readonly cloud = false) {}
|
||||||
|
|
||||||
register(provider: IntegrationProvider): void {
|
register(provider: IntegrationProvider): void {
|
||||||
const { type } = provider.definition;
|
const { type } = provider.definition;
|
||||||
if (this.providers.has(type)) {
|
if (this.providers.has(type)) {
|
||||||
throw new Error(`Integration provider "${type}" is already registered`);
|
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);
|
this.providers.set(type, provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,32 @@
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import { UnfurlService } from './unfurl.service';
|
import { UnfurlService } from './unfurl.service';
|
||||||
import { IntegrationRegistry } from '../registry/integration-registry';
|
import { IntegrationRegistry } from '../registry/integration-registry';
|
||||||
import { GitHubProvider } from '../providers/github/github.provider';
|
import { GitHubProvider } from '../providers/github/github.provider';
|
||||||
import {
|
import {
|
||||||
IntegrationDefinition,
|
IntegrationDefinition,
|
||||||
IntegrationProvider,
|
IntegrationProvider,
|
||||||
|
ProviderApiError,
|
||||||
TokenExpiredError,
|
TokenExpiredError,
|
||||||
TokenInvalidError,
|
TokenInvalidError,
|
||||||
} from '../registry/integration-provider.interface';
|
} from '../registry/integration-provider.interface';
|
||||||
|
|
||||||
const GHE_BASE = 'https://github.acme.com';
|
const GHE_BASE = 'https://github.acme.com';
|
||||||
const CONNECTED_AT = new Date('2026-10-01T09:00:00Z');
|
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 {
|
class WorkspaceScopedProvider extends IntegrationProvider {
|
||||||
definition: IntegrationDefinition = {
|
definition: IntegrationDefinition = {
|
||||||
|
|
@ -102,7 +118,7 @@ describe('UnfurlService provider resolution', () => {
|
||||||
title: 'PR',
|
title: 'PR',
|
||||||
});
|
});
|
||||||
expect(unfurlPullRequest).toHaveBeenCalledWith(
|
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 () => {
|
it('asks a disconnected member to connect instead of serving their cached card', async () => {
|
||||||
const { service, connectionRepo, redis } = build();
|
const { service, unfurlPullRequest, connectionRepo, redis } = build();
|
||||||
redis.get.mockResolvedValue(cachedCard);
|
redis.get.mockImplementation(async (key: string) =>
|
||||||
|
key.startsWith('unfurl:w1:') ? cachedCard : null,
|
||||||
|
);
|
||||||
connectionRepo.findByIntegrationAndUser.mockResolvedValue(undefined);
|
connectionRepo.findByIntegrationAndUser.mockResolvedValue(undefined);
|
||||||
|
unfurlPullRequest.mockRejectedValue(
|
||||||
|
new ProviderApiError('GitHub', 404, 'Not Found'),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(service.unfurl(url, 'u1', 'w1')).resolves.toMatchObject({
|
await expect(service.unfurl(url, 'u1', 'w1')).resolves.toMatchObject({
|
||||||
needsConnection: true,
|
needsConnection: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(redis.get).not.toHaveBeenCalled();
|
expect(redis.get).not.toHaveBeenCalledWith(
|
||||||
expect(redis.set).not.toHaveBeenCalled();
|
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 () => {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import type { Redis } from 'ioredis';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
const UNFURL_CACHE_TTL = 300; // 5 minutes
|
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_ERROR_CACHE_TTL = 60;
|
||||||
const UNFURL_CACHE_PREFIX = 'unfurl:';
|
const UNFURL_CACHE_PREFIX = 'unfurl:';
|
||||||
|
|
||||||
|
|
@ -71,6 +71,15 @@ export class UnfurlService {
|
||||||
if (connectionScope === 'workspace') {
|
if (connectionScope === 'workspace') {
|
||||||
return null;
|
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.
|
// Not cached, so the card loads once the user connects.
|
||||||
return this.buildNeedsConnection(
|
return this.buildNeedsConnection(
|
||||||
provider,
|
provider,
|
||||||
|
|
@ -152,11 +161,7 @@ export class UnfurlService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.logger.error(`Unfurl failed for ${url}: ${(err as Error).message}`);
|
this.logger.error(`Unfurl failed for ${url}: ${(err as Error).message}`);
|
||||||
const ttl =
|
await this.cacheNull(cacheKey, UNFURL_ERROR_CACHE_TTL);
|
||||||
err instanceof ProviderApiError && err.status === 404
|
|
||||||
? UNFURL_CACHE_TTL
|
|
||||||
: UNFURL_ERROR_CACHE_TTL;
|
|
||||||
await this.cacheNull(cacheKey, ttl);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -165,6 +170,50 @@ export class UnfurlService {
|
||||||
await this.redis.set(cacheKey, 'null', 'EX', ttl);
|
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(
|
private buildNeedsConnection(
|
||||||
provider: IntegrationProvider,
|
provider: IntegrationProvider,
|
||||||
integrationId: string,
|
integrationId: string,
|
||||||
|
|
|
||||||
16
apps/server/src/core/page/dto/create-page.dto.spec.ts
Normal file
16
apps/server/src/core/page/dto/create-page.dto.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -32,5 +32,5 @@ export class CreatePageDto {
|
||||||
@ValidateIf((o) => o.content !== undefined)
|
@ValidateIf((o) => o.content !== undefined)
|
||||||
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
|
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
|
||||||
@IsIn(['json', 'markdown', 'html'])
|
@IsIn(['json', 'markdown', 'html'])
|
||||||
format?: ContentFormat;
|
format?: ContentFormat = 'json';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
apps/server/src/core/page/dto/update-page.dto.spec.ts
Normal file
17
apps/server/src/core/page/dto/update-page.dto.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -20,5 +20,5 @@ export class UpdatePageDto extends PartialType(CreatePageDto) {
|
||||||
@ValidateIf((o) => o.content !== undefined)
|
@ValidateIf((o) => o.content !== undefined)
|
||||||
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
|
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
|
||||||
@IsIn(['json', 'markdown', 'html'])
|
@IsIn(['json', 'markdown', 'html'])
|
||||||
format?: ContentFormat;
|
format?: ContentFormat = 'json';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit 7eb0ef1dfa2f5ed9e6331e8d068cbbe15be25d7d
|
Subproject commit 481e7791f8bbcdc368e31c012f34cc52a4227587
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
OAUTH_AUTHORIZE_THROTTLER,
|
OAUTH_AUTHORIZE_THROTTLER,
|
||||||
SIEM_TEST_THROTTLER,
|
SIEM_TEST_THROTTLER,
|
||||||
UNFURL_THROTTLER,
|
UNFURL_THROTTLER,
|
||||||
|
CLOUD_EMAIL_THROTTLER,
|
||||||
} from './throttler-names';
|
} from './throttler-names';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
|
@ -31,6 +32,7 @@ import Redis from 'ioredis';
|
||||||
{ name: OAUTH_AUTHORIZE_THROTTLER, ttl: 60_000, limit: 30 },
|
{ name: OAUTH_AUTHORIZE_THROTTLER, ttl: 60_000, limit: 30 },
|
||||||
{ name: SIEM_TEST_THROTTLER, ttl: 60_000, limit: 10 },
|
{ name: SIEM_TEST_THROTTLER, ttl: 60_000, limit: 10 },
|
||||||
{ name: UNFURL_THROTTLER, ttl: 60_000, limit: 120 },
|
{ name: UNFURL_THROTTLER, ttl: 60_000, limit: 120 },
|
||||||
|
{ name: CLOUD_EMAIL_THROTTLER, ttl: 60_000, limit: 5 },
|
||||||
],
|
],
|
||||||
errorMessage: 'Too many requests',
|
errorMessage: 'Too many requests',
|
||||||
storage: new ThrottlerStorageRedisService(
|
storage: new ThrottlerStorageRedisService(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export const OAUTH_TOKEN_THROTTLER = 'oauth-token';
|
||||||
export const OAUTH_AUTHORIZE_THROTTLER = 'oauth-authorize';
|
export const OAUTH_AUTHORIZE_THROTTLER = 'oauth-authorize';
|
||||||
export const SIEM_TEST_THROTTLER = 'siem-test';
|
export const SIEM_TEST_THROTTLER = 'siem-test';
|
||||||
export const UNFURL_THROTTLER = 'integration-unfurl';
|
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.
|
// 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> = {
|
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,
|
[OAUTH_AUTHORIZE_THROTTLER]: true,
|
||||||
[SIEM_TEST_THROTTLER]: true,
|
[SIEM_TEST_THROTTLER]: true,
|
||||||
[UNFURL_THROTTLER]: true,
|
[UNFURL_THROTTLER]: true,
|
||||||
|
[CLOUD_EMAIL_THROTTLER]: true,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "docmost",
|
"name": "docmost",
|
||||||
"homepage": "https://docmost.com",
|
"homepage": "https://docmost.com",
|
||||||
"version": "0.96.0",
|
"version": "0.97.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nx run-many -t build",
|
"build": "nx run-many -t build",
|
||||||
|
|
@ -54,7 +54,6 @@
|
||||||
"@tiptap/extension-typography": "3.31.3",
|
"@tiptap/extension-typography": "3.31.3",
|
||||||
"@tiptap/extension-unique-id": "3.31.3",
|
"@tiptap/extension-unique-id": "3.31.3",
|
||||||
"@tiptap/extension-youtube": "3.31.3",
|
"@tiptap/extension-youtube": "3.31.3",
|
||||||
"@tiptap/html": "3.31.3",
|
|
||||||
"@tiptap/pm": "3.31.3",
|
"@tiptap/pm": "3.31.3",
|
||||||
"@tiptap/react": "3.31.3",
|
"@tiptap/react": "3.31.3",
|
||||||
"@tiptap/starter-kit": "3.31.3",
|
"@tiptap/starter-kit": "3.31.3",
|
||||||
|
|
@ -65,7 +64,7 @@
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"diff": "8.0.3",
|
"diff": "8.0.3",
|
||||||
"docx": "9.7.1",
|
"docx": "9.7.1",
|
||||||
"dompurify": "3.4.16",
|
"dompurify": "3.4.17",
|
||||||
"fractional-indexing-jittered": "1.0.0",
|
"fractional-indexing-jittered": "1.0.0",
|
||||||
"highlight.js": "11.11.1",
|
"highlight.js": "11.11.1",
|
||||||
"image-dimensions": "2.5.0",
|
"image-dimensions": "2.5.0",
|
||||||
|
|
|
||||||
642
pnpm-lock.yaml
642
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -7,20 +7,20 @@ overrides:
|
||||||
prosemirror-changeset: 2.4.0
|
prosemirror-changeset: 2.4.0
|
||||||
glob: 13.0.6
|
glob: 13.0.6
|
||||||
ws: 8.21.3
|
ws: 8.21.3
|
||||||
dompurify: 3.4.16
|
dompurify: 3.4.17
|
||||||
mermaid: 11.16.1
|
mermaid: 11.16.1
|
||||||
katex: 0.19.0
|
katex: 0.19.0
|
||||||
undici: 7.29.1
|
undici: 7.30.1
|
||||||
tmp: 0.2.7
|
tmp: 0.2.7
|
||||||
nanoid@^3: 3.3.18
|
nanoid@^3: 3.3.18
|
||||||
lodash-es: 4.18.1
|
lodash-es: 4.18.1
|
||||||
flatted: 3.4.2
|
flatted: 3.4.2
|
||||||
find-my-way: 9.7.0
|
find-my-way: 9.7.0
|
||||||
fastify: 5.12.5
|
fastify: 5.12.7
|
||||||
yaml@>=2.0.0 <2.8.3: 2.8.3
|
yaml@>=2.0.0 <2.8.3: 2.8.3
|
||||||
brace-expansion@^5: 5.0.12
|
brace-expansion@^5: 5.0.12
|
||||||
axios: 1.20.0
|
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
|
form-data@>=4.0.0 <4.0.6: 4.0.6
|
||||||
nanoid@>=4.0.0 <5.1.16: 5.1.16
|
nanoid@>=4.0.0 <5.1.16: 5.1.16
|
||||||
esbuild@>=0.27.3 <0.28.1: 0.28.1
|
esbuild@>=0.27.3 <0.28.1: 0.28.1
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue