Merge remote-tracking branch 'gitea/develop' into feat/ai-chat-collapse-on-focus
This commit is contained in:
@@ -391,6 +391,13 @@
|
||||
"Toggle block": "Сворачиваемый блок",
|
||||
"Callout": "Выноска",
|
||||
"Insert callout notice.": "Вставить выноску с сообщением.",
|
||||
"Footnote": "Сноска",
|
||||
"Insert a footnote reference.": "Вставить ссылку на сноску.",
|
||||
"Footnotes": "Примечания",
|
||||
"Footnote {{number}}": "Сноска {{number}}",
|
||||
"Go to footnote": "Перейти к сноске",
|
||||
"Back to reference": "Вернуться к ссылке",
|
||||
"Empty footnote": "Пустая сноска",
|
||||
"Math inline": "Строчная формула",
|
||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||
"Math block": "Блок формулы",
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMatch } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -148,13 +148,16 @@ export default function AiChatWindow() {
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
|
||||
// The page the user is currently viewing, derived from the route (same
|
||||
// source the breadcrumb uses). On a non-page route `pageSlug` is undefined,
|
||||
// so the query is disabled and `openPage` is null. This is passed to the
|
||||
// chat thread as context so the agent knows what "this page"/"the current
|
||||
// page" refers to; the agent still reads/writes via its CASL-enforced page
|
||||
// tools using the id.
|
||||
const { pageSlug } = useParams();
|
||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||
// pathname against the authenticated page route instead so "the current page"
|
||||
// resolves regardless of where this component is mounted. On a non-page route
|
||||
// the match is null, so `pageSlug` is undefined, the query is disabled and
|
||||
// `openPage` is null. This is passed to the chat thread as context so the
|
||||
// agent knows what "this page"/"the current page" refers to; the agent still
|
||||
// reads/writes via its CASL-enforced page tools using the id.
|
||||
const pageRouteMatch = useMatch("/s/:spaceSlug/p/:pageSlug");
|
||||
const pageSlug = pageRouteMatch?.params?.pageSlug;
|
||||
const { data: openPageData } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
|
||||
@@ -10,6 +10,18 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface MessageItemProps {
|
||||
message: UIMessage;
|
||||
/**
|
||||
* Forwarded to ToolCallCard: whether tool cards render page citation links.
|
||||
* Defaults to true (internal chat). The public share passes false.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Neutralize internal/relative markdown links in the rendered answer (drop
|
||||
* their href so they become inert text). Defaults to false (internal chat,
|
||||
* links stay clickable). The anonymous public share passes true so internal
|
||||
* UUIDs/routes in the assistant's markdown don't leak as clickable links.
|
||||
*/
|
||||
neutralizeInternalLinks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,7 +36,11 @@ interface MessageItemProps {
|
||||
* `message` prop identity (and its `parts`) changes each tick. Re-rendering the
|
||||
* text parts on each delta is what makes the answer stream in progressively.
|
||||
*/
|
||||
export default function MessageItem({ message }: MessageItemProps) {
|
||||
export default function MessageItem({
|
||||
message,
|
||||
showCitations = true,
|
||||
neutralizeInternalLinks = false,
|
||||
}: MessageItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const isUser = message.role === "user";
|
||||
|
||||
@@ -53,7 +69,9 @@ export default function MessageItem({ message }: MessageItemProps) {
|
||||
// starts with an empty text part before the first token arrives); the
|
||||
// typing indicator covers that gap until real content streams in.
|
||||
if (!part.text.trim()) return null;
|
||||
const html = renderChatMarkdown(part.text);
|
||||
const html = renderChatMarkdown(part.text, {
|
||||
neutralizeInternalLinks,
|
||||
});
|
||||
if (html) {
|
||||
return (
|
||||
<div
|
||||
@@ -73,7 +91,13 @@ export default function MessageItem({ message }: MessageItemProps) {
|
||||
}
|
||||
|
||||
if (isToolPart(part.type)) {
|
||||
return <ToolCallCard key={index} part={part as unknown as ToolUiPart} />;
|
||||
return (
|
||||
<ToolCallCard
|
||||
key={index}
|
||||
part={part as unknown as ToolUiPart}
|
||||
showCitations={showCitations}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import { Center, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
@@ -10,6 +10,26 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
interface MessageListProps {
|
||||
messages: UIMessage[];
|
||||
isStreaming: boolean;
|
||||
/**
|
||||
* Content shown when the transcript is empty and no turn is in flight.
|
||||
* Defaults to the internal chat's prompt. The public share passes its own
|
||||
* documentation-focused copy. This is purely the empty-state text; the
|
||||
* streaming/typing/markdown/tool-card paths below are shared verbatim.
|
||||
*/
|
||||
emptyState?: ReactNode;
|
||||
/**
|
||||
* Forwarded to MessageItem -> ToolCallCard: whether tool cards render page
|
||||
* citation links. Defaults to true (internal chat). The public share passes
|
||||
* false because an anonymous reader cannot open the linked internal pages.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
||||
* the rendered answers (drop their href so they render as inert text).
|
||||
* Defaults to false (internal chat). The public share passes true so internal
|
||||
* UUIDs/routes don't leak as clickable links to anonymous readers.
|
||||
*/
|
||||
neutralizeInternalLinks?: boolean;
|
||||
}
|
||||
|
||||
// Distance (px) from the bottom within which the viewport still counts as
|
||||
@@ -24,7 +44,7 @@ const BOTTOM_THRESHOLD = 40;
|
||||
* - the last (assistant) message has no non-empty text and no tool part.
|
||||
* Once any text/tool part arrives, MessageItem renders it and this hides.
|
||||
*/
|
||||
function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
|
||||
export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
|
||||
if (!isStreaming) return false;
|
||||
const last = messages[messages.length - 1];
|
||||
if (!last) return true; // submitted with nothing rendered yet.
|
||||
@@ -41,7 +61,13 @@ function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boole
|
||||
* but only while the user is pinned to the bottom — if they scrolled up to read
|
||||
* earlier messages, streamed deltas no longer yank them back down.
|
||||
*/
|
||||
export default function MessageList({ messages, isStreaming }: MessageListProps) {
|
||||
export default function MessageList({
|
||||
messages,
|
||||
isStreaming,
|
||||
emptyState,
|
||||
showCitations = true,
|
||||
neutralizeInternalLinks = false,
|
||||
}: MessageListProps) {
|
||||
const { t } = useTranslation();
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
// Whether the viewport is currently pinned to the bottom. Starts true so the
|
||||
@@ -104,9 +130,11 @@ export default function MessageList({ messages, isStreaming }: MessageListProps)
|
||||
if (messages.length === 0 && !typing) {
|
||||
return (
|
||||
<Center className={classes.messages}>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("Ask the AI agent anything about your workspace.")}
|
||||
</Text>
|
||||
{emptyState ?? (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("Ask the AI agent anything about your workspace.")}
|
||||
</Text>
|
||||
)}
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -115,7 +143,12 @@ export default function MessageList({ messages, isStreaming }: MessageListProps)
|
||||
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
||||
<Stack gap={0} pr="xs">
|
||||
{messages.map((message) => (
|
||||
<MessageItem key={message.id} message={message} />
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
showCitations={showCitations}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
/>
|
||||
))}
|
||||
{typing && <TypingIndicator />}
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { showTypingIndicator } from "@/features/ai-chat/components/message-list.tsx";
|
||||
|
||||
/**
|
||||
* Pure-helper tests for the typing-indicator bridging logic that the internal
|
||||
* chat and the public share widget now share. This is the behavior that decides
|
||||
* whether the animated "AI agent is typing…" placeholder shows in the gap
|
||||
* between sending and the first streamed token.
|
||||
*/
|
||||
const msg = (
|
||||
role: "user" | "assistant",
|
||||
parts: UIMessage["parts"],
|
||||
): UIMessage => ({ id: Math.random().toString(), role, parts }) as UIMessage;
|
||||
|
||||
describe("showTypingIndicator", () => {
|
||||
it("is hidden when not streaming", () => {
|
||||
expect(showTypingIndicator([], false)).toBe(false);
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [{ type: "text", text: "hi" }])], false),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows while streaming with no messages yet (just submitted)", () => {
|
||||
expect(showTypingIndicator([], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows while streaming when the last message is still the user's", () => {
|
||||
expect(
|
||||
showTypingIndicator([msg("user", [{ type: "text", text: "q" }])], true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows while streaming when the assistant row has no visible content", () => {
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [{ type: "text", text: "" }])], true),
|
||||
).toBe(true);
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [{ type: "text", text: " " }])], true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides once the assistant streams non-empty text", () => {
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [{ type: "text", text: "answer" }])], true),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides once a tool part appears (even before any text)", () => {
|
||||
const toolPart = { type: "tool-searchPages" } as unknown as UIMessage["parts"][number];
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [toolPart])], true),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,14 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface ToolCallCardProps {
|
||||
part: ToolUiPart;
|
||||
/**
|
||||
* Whether to render page citation links. Defaults to true (the internal chat,
|
||||
* where the reader is authenticated and the `/p/{id}` links resolve). The
|
||||
* public share passes false: an anonymous reader cannot open internal pages,
|
||||
* so the links would 404/redirect to login. Suppressing them keeps the card
|
||||
* (the action log itself) while dropping the unusable links.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,12 +28,15 @@ interface ToolCallCardProps {
|
||||
* agent DID (the agent writes without confirmation — D2), its run state
|
||||
* (running / done / error), and citation link(s) to any referenced page(s).
|
||||
*/
|
||||
export default function ToolCallCard({ part }: ToolCallCardProps) {
|
||||
export default function ToolCallCard({
|
||||
part,
|
||||
showCitations = true,
|
||||
}: ToolCallCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const toolName = getToolName(part);
|
||||
const state = toolRunState(part.state);
|
||||
const { key, values } = toolLabelKey(toolName);
|
||||
const citations = toolCitations(part);
|
||||
const citations = showCitations ? toolCitations(part) : [];
|
||||
|
||||
return (
|
||||
<div className={classes.toolCard}>
|
||||
|
||||
53
apps/client/src/features/ai-chat/utils/error-message.test.ts
Normal file
53
apps/client/src/features/ai-chat/utils/error-message.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeChatError } from "./error-message";
|
||||
|
||||
// Identity translator: assert on the raw English key so the tests do not depend
|
||||
// on the i18n catalog.
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe("describeChatError", () => {
|
||||
it('surfaces a provider "402: ..." stream error verbatim', () => {
|
||||
expect(describeChatError("402: Insufficient credits", t)).toBe(
|
||||
"402: Insufficient credits",
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT misclassify a body that merely contains "403" (no "statusCode":403)', () => {
|
||||
// A provider message mentioning the number 403 must be surfaced verbatim,
|
||||
// never folded into the "AI chat is disabled" gating message.
|
||||
const msg = "429: rate limited after 403 attempts";
|
||||
expect(describeChatError(msg, t)).toBe(msg);
|
||||
});
|
||||
|
||||
it('maps a {"statusCode":403} body to the disabled message', () => {
|
||||
const body = '{"statusCode":403,"message":"Forbidden"}';
|
||||
expect(describeChatError(body, t)).toBe(
|
||||
"AI chat is disabled for this workspace.",
|
||||
);
|
||||
});
|
||||
|
||||
it('maps a {"statusCode":503} body to the not-configured message', () => {
|
||||
const body = '{"statusCode":503,"message":"Service Unavailable"}';
|
||||
expect(describeChatError(body, t)).toBe(
|
||||
"The AI provider is not configured. Ask an administrator to set it up.",
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the generic message for "An error occurred."', () => {
|
||||
expect(describeChatError("An error occurred.", t)).toBe(
|
||||
"The AI agent could not respond. Please try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the generic message for "Internal server error"', () => {
|
||||
expect(describeChatError("Internal server error", t)).toBe(
|
||||
"The AI agent could not respond. Please try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the generic message for empty input", () => {
|
||||
expect(describeChatError("", t)).toBe(
|
||||
"The AI agent could not respond. Please try again.",
|
||||
);
|
||||
});
|
||||
});
|
||||
117
apps/client/src/features/ai-chat/utils/markdown.test.ts
Normal file
117
apps/client/src/features/ai-chat/utils/markdown.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
|
||||
/**
|
||||
* Tests for the internal-link neutralization used by the anonymous public
|
||||
* share. Now that the share renders the assistant's MARKDOWN (not plain text),
|
||||
* internal app links (e.g. `[page](/p/{uuid})`) would otherwise become clickable
|
||||
* `<a href="/p/...">`, leaking internal UUIDs/structure and linking to auth-gated
|
||||
* routes. With the flag ON those links are made inert (href removed) while the
|
||||
* visible text and the rest of the markdown formatting are preserved; genuinely
|
||||
* EXTERNAL http(s) links (a DIFFERENT host than the app's own origin) are kept
|
||||
* with a safe rel/target, while absolute links back to our OWN origin are
|
||||
* neutralized too. With the flag OFF (internal default) links keep their href so
|
||||
* the authenticated chat is unchanged.
|
||||
*/
|
||||
|
||||
/** Parse the rendered HTML and return the first <a> element (or null). */
|
||||
function firstAnchor(html: string): HTMLAnchorElement | null {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
return doc.querySelector("a");
|
||||
}
|
||||
|
||||
describe("renderChatMarkdown — internal link neutralization", () => {
|
||||
it("makes an internal link inert when the flag is ON (no href, text kept)", () => {
|
||||
const html = renderChatMarkdown("[x](/p/abc)", {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.hasAttribute("href")).toBe(false);
|
||||
expect(a!.hasAttribute("target")).toBe(false);
|
||||
// Visible link text is preserved.
|
||||
expect(a!.textContent).toBe("x");
|
||||
});
|
||||
|
||||
it("neutralizes bare-fragment links when the flag is ON", () => {
|
||||
const html = renderChatMarkdown("[here](#section)", {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.hasAttribute("href")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an external http(s) link with a safe rel/target when the flag is ON", () => {
|
||||
const html = renderChatMarkdown("[y](https://example.com/x)", {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.getAttribute("href")).toBe("https://example.com/x");
|
||||
expect(a!.getAttribute("rel")).toBe("noopener noreferrer nofollow");
|
||||
expect(a!.getAttribute("target")).toBe("_blank");
|
||||
});
|
||||
|
||||
it("neutralizes an absolute link to our OWN origin when the flag is ON", () => {
|
||||
// An LLM can emit an absolute URL back at our own host (e.g.
|
||||
// `http://self/p/{uuid}`); it is internal and must be made inert just like a
|
||||
// relative `/p/...` link, not kept clickable as if it were external.
|
||||
const ownOrigin = `${window.location.origin}/p/abc`;
|
||||
const html = renderChatMarkdown(`[x](${ownOrigin})`, {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.hasAttribute("href")).toBe(false);
|
||||
expect(a!.hasAttribute("target")).toBe(false);
|
||||
expect(a!.textContent).toBe("x");
|
||||
});
|
||||
|
||||
it("neutralizes dangerous/unsafe schemes when the flag is ON", () => {
|
||||
// javascript:, data:, and protocol-relative `//...` must never stay
|
||||
// clickable on the anonymous share — they are not genuinely external
|
||||
// http(s) links to a different host, so the href is dropped (or sanitized
|
||||
// away entirely by DOMPurify).
|
||||
for (const markdown of [
|
||||
"[a](javascript:alert(1))",
|
||||
"[b](data:text/html,<script>alert(1)</script>)",
|
||||
"[c](//evil.com/x)",
|
||||
]) {
|
||||
const html = renderChatMarkdown(markdown, {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
// Either the anchor was stripped of its href, or DOMPurify removed the
|
||||
// unsafe href outright; in both cases nothing dangerous remains.
|
||||
if (a !== null) {
|
||||
expect(a.hasAttribute("href")).toBe(false);
|
||||
expect(a.hasAttribute("target")).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps internal links clickable when the flag is OFF (internal default)", () => {
|
||||
const html = renderChatMarkdown("[x](/p/abc)");
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.getAttribute("href")).toBe("/p/abc");
|
||||
});
|
||||
|
||||
it("keeps an absolute own-origin link clickable when the flag is OFF (internal default)", () => {
|
||||
const ownOrigin = `${window.location.origin}/p/abc`;
|
||||
const html = renderChatMarkdown(`[x](${ownOrigin})`);
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.getAttribute("href")).toBe(ownOrigin);
|
||||
});
|
||||
|
||||
it("does not leave a global DOMPurify hook that affects a later internal render", () => {
|
||||
// A neutralizing render first, then an internal render: the internal link
|
||||
// must survive (the hook is removed after the share render).
|
||||
renderChatMarkdown("[x](/p/abc)", { neutralizeInternalLinks: true });
|
||||
const html = renderChatMarkdown("[x](/p/abc)");
|
||||
const a = firstAnchor(html);
|
||||
expect(a!.getAttribute("href")).toBe("/p/abc");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,66 @@
|
||||
import { markdownToHtml } from "@docmost/editor-ext";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export interface RenderChatMarkdownOptions {
|
||||
/**
|
||||
* Neutralize INTERNAL links so they render as inert text (no `href`/`target`).
|
||||
* Used by the anonymous public share: the assistant's answer can contain
|
||||
* relative app links (e.g. `[page](/p/{uuid})`, `[settings](/settings/members)`)
|
||||
* that would otherwise become clickable `<a href="/p/...">`, leaking internal
|
||||
* UUIDs/structure and pointing at auth-gated routes. An anonymous reader can
|
||||
* still follow genuinely EXTERNAL `http(s)` links (a DIFFERENT host than the
|
||||
* app's own origin), so those are kept (with a safe `rel`/`target`); absolute
|
||||
* links back to our OWN origin (e.g. `https://self/p/{uuid}`) are internal and
|
||||
* neutralized too. Defaults to false — the internal chat keeps internal links
|
||||
* clickable for authenticated users.
|
||||
*/
|
||||
neutralizeInternalLinks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `href` points at an EXTERNAL absolute URL we are happy for an
|
||||
* anonymous reader to follow. A link qualifies only if it is absolute
|
||||
* `http(s)://` AND its host differs from the app's own origin
|
||||
* (`window.location.host`): absolute links back to our OWN host (e.g.
|
||||
* `https://self/p/{uuid}`) are internal and must be neutralized, exactly like
|
||||
* relative `/p/...` links. Everything else (relative `/...`, bare fragments
|
||||
* `#...`, protocol-relative `//...`, other schemes, or anything that does not
|
||||
* parse) is treated as internal/unsafe and neutralized — fail closed.
|
||||
*/
|
||||
function isExternalHttpUrl(href: string): boolean {
|
||||
const value = href.trim();
|
||||
if (!/^https?:\/\//i.test(value)) return false;
|
||||
try {
|
||||
// External only if it points at a DIFFERENT host than the app's own origin.
|
||||
// Absolute links back to our own host (e.g. https://self/p/{uuid}) are
|
||||
// internal and must be neutralized, same as relative `/p/...` links.
|
||||
return new URL(value).host !== window.location.host;
|
||||
} catch {
|
||||
return false; // unparseable -> treat as internal/unsafe, neutralize
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DOMPurify `afterSanitizeAttributes` hook that neutralizes internal links.
|
||||
* Hooks are GLOBAL on the DOMPurify instance, so this is only ever registered
|
||||
* for the duration of a single sanitize call (added then removed in
|
||||
* `renderChatMarkdown`) — it must never leak into the internal chat's renders.
|
||||
*/
|
||||
function neutralizeInternalLinksHook(node: Element): void {
|
||||
if (node.nodeName !== "A") return;
|
||||
const href = node.getAttribute("href");
|
||||
if (href !== null && isExternalHttpUrl(href)) {
|
||||
// Genuinely external link: keep it, but force a safe rel/target.
|
||||
node.setAttribute("rel", "noopener noreferrer nofollow");
|
||||
node.setAttribute("target", "_blank");
|
||||
return;
|
||||
}
|
||||
// Internal/relative/fragment link (or no href): make it inert text. Drop the
|
||||
// href and any target so it is no longer clickable; the visible text stays.
|
||||
node.removeAttribute("href");
|
||||
node.removeAttribute("target");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render AI markdown to sanitized HTML for read-only display. We reuse the
|
||||
* app's `markdownToHtml` (the same `marked` pipeline used for paste/import) so
|
||||
@@ -12,9 +72,31 @@ import DOMPurify from "dompurify";
|
||||
* synchronously, but we guard the Promise case by returning a safe empty string
|
||||
* for that branch (the caller renders the raw text fallback instead).
|
||||
*/
|
||||
export function renderChatMarkdown(markdown: string): string {
|
||||
export function renderChatMarkdown(
|
||||
markdown: string,
|
||||
options: RenderChatMarkdownOptions = {},
|
||||
): string {
|
||||
if (!markdown) return "";
|
||||
const html = markdownToHtml(markdown);
|
||||
if (typeof html !== "string") return "";
|
||||
return DOMPurify.sanitize(html);
|
||||
|
||||
if (!options.neutralizeInternalLinks) {
|
||||
// Internal chat: unchanged behavior, no hook registered.
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
|
||||
// Public share: register the neutralization hook only for THIS sanitize call,
|
||||
// then remove it immediately so it can never affect the internal chat (hooks
|
||||
// are global on the shared DOMPurify instance).
|
||||
DOMPurify.addHook("afterSanitizeAttributes", neutralizeInternalLinksHook);
|
||||
try {
|
||||
return DOMPurify.sanitize(html);
|
||||
} finally {
|
||||
// Remove by reference (not a bare pop) so we only ever remove OUR hook,
|
||||
// robust to any other afterSanitizeAttributes hook registered in future.
|
||||
DOMPurify.removeHook(
|
||||
"afterSanitizeAttributes",
|
||||
neutralizeInternalLinksHook,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
100
apps/client/src/features/ai-chat/utils/tool-parts.test.tsx
Normal file
100
apps/client/src/features/ai-chat/utils/tool-parts.test.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
toolCitations,
|
||||
toolRunState,
|
||||
type ToolUiPart,
|
||||
} from "./tool-parts";
|
||||
|
||||
describe("toolCitations", () => {
|
||||
it("emits one citation per searchPages item with a /p/{id} href", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-searchPages",
|
||||
state: "output-available",
|
||||
output: [
|
||||
{ id: "p1", title: "First" },
|
||||
{ id: "p2", title: "Second" },
|
||||
],
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([
|
||||
{ pageId: "p1", title: "First", href: "/p/p1" },
|
||||
{ pageId: "p2", title: "Second", href: "/p/p2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops searchPages items missing an id", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-searchPages",
|
||||
state: "output-available",
|
||||
output: [{ title: "No id here" }, { id: "p2", title: "Kept" }],
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([
|
||||
{ pageId: "p2", title: "Kept", href: "/p/p2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to input.pageId / input.title for a page-op with only pageId", () => {
|
||||
// The mutating tools echo `pageId` (no `id`); title is taken from the input.
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-updatePageContent",
|
||||
state: "output-available",
|
||||
input: { pageId: "host-1", title: "From input" },
|
||||
output: { pageId: "host-1" },
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([
|
||||
{ pageId: "host-1", title: "From input", href: "/p/host-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers output.id over input.pageId when both exist", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
input: { pageId: "input-id", title: "Input title" },
|
||||
output: { id: "output-id", title: "Output title" },
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([
|
||||
{ pageId: "output-id", title: "Output title", href: "/p/output-id" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] when the state is not output-available", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-getPage",
|
||||
state: "input-available",
|
||||
output: { id: "p1", title: "Pending" },
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for a page-op output with no resolvable id", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
input: {},
|
||||
output: { title: "Only a title" },
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolRunState", () => {
|
||||
it('maps "output-error" to error', () => {
|
||||
expect(toolRunState("output-error")).toBe("error");
|
||||
});
|
||||
|
||||
it('maps "output-denied" to error', () => {
|
||||
expect(toolRunState("output-denied")).toBe("error");
|
||||
});
|
||||
|
||||
it('maps "output-available" to done', () => {
|
||||
expect(toolRunState("output-available")).toBe("done");
|
||||
});
|
||||
|
||||
it('maps "input-available" to running', () => {
|
||||
expect(toolRunState("input-available")).toBe("running");
|
||||
});
|
||||
|
||||
it("maps undefined to running", () => {
|
||||
expect(toolRunState(undefined)).toBe("running");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFootnoteNumber } from "@docmost/editor-ext";
|
||||
import classes from "./footnote.module.css";
|
||||
|
||||
/**
|
||||
* NodeView for a single footnote definition: a decorative number marker, the
|
||||
* editable content (NodeViewContent), and a "↩" back-link to its reference.
|
||||
* The number is derived from the document (not stored).
|
||||
*/
|
||||
export default function FootnoteDefinitionView(props: NodeViewProps) {
|
||||
const { node, editor } = props;
|
||||
const { t } = useTranslation();
|
||||
const id = node.attrs.id as string;
|
||||
|
||||
// Read the cached number from the numbering plugin (computed once per doc
|
||||
// change) rather than recomputing the whole map on every render.
|
||||
const number = getFootnoteNumber(editor.state, id) ?? "?";
|
||||
|
||||
const handleBack = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
editor.commands.scrollToReference(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
data-footnote-def=""
|
||||
data-id={id}
|
||||
className={classes.definition}
|
||||
style={{ ["--footnote-number" as any]: `"${number}"` }}
|
||||
>
|
||||
<span className={classes.definitionMarker} contentEditable={false}>
|
||||
{number}.
|
||||
</span>
|
||||
<NodeViewContent className={classes.definitionContent} />
|
||||
<span
|
||||
className={classes.backLink}
|
||||
contentEditable={false}
|
||||
onClick={handleBack}
|
||||
role="button"
|
||||
aria-label={t("Back to reference")}
|
||||
title={t("Back to reference")}
|
||||
>
|
||||
↩
|
||||
</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
import {
|
||||
FOOTNOTE_DEFINITION_NAME,
|
||||
getFootnoteNumber,
|
||||
} from "@docmost/editor-ext";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconArrowDown } from "@tabler/icons-react";
|
||||
import classes from "./footnote.module.css";
|
||||
|
||||
/**
|
||||
* Read the plain text of the footnote definition with `id` directly from the
|
||||
* editor state. No sub-editor: the popover is read-only.
|
||||
*/
|
||||
function getDefinitionText(editor: NodeViewProps["editor"], id: string): string {
|
||||
let text = "";
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (
|
||||
node.type.name === FOOTNOTE_DEFINITION_NAME &&
|
||||
node.attrs.id === id
|
||||
) {
|
||||
text = node.textContent;
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function FootnoteReferenceView(props: NodeViewProps) {
|
||||
const { node, editor, selected } = props;
|
||||
const { t } = useTranslation();
|
||||
const id = node.attrs.id as string;
|
||||
|
||||
const anchorRef = useRef<HTMLElement | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Number is derived (not stored). Read it from the numbering plugin's cached
|
||||
// map (computed once per doc change) instead of walking the whole document on
|
||||
// every render — recomputing per NodeView per render was O(n^2) per keystroke.
|
||||
const number = getFootnoteNumber(editor.state, id) ?? "?";
|
||||
const defText = open ? getDefinitionText(editor, id) : "";
|
||||
|
||||
const position = useCallback(() => {
|
||||
const anchor = anchorRef.current;
|
||||
const popup = popoverRef.current;
|
||||
if (!anchor || !popup) return;
|
||||
computePosition(anchor, popup, {
|
||||
placement: "top",
|
||||
middleware: [offset(6), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
popup.style.left = `${x}px`;
|
||||
popup.style.top = `${y}px`;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const anchor = anchorRef.current;
|
||||
const popup = popoverRef.current;
|
||||
if (!anchor || !popup) return;
|
||||
|
||||
const cleanup = autoUpdate(anchor, popup, position);
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (
|
||||
popup.contains(e.target as Node) ||
|
||||
anchor.contains(e.target as Node)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
};
|
||||
}, [open, position]);
|
||||
|
||||
const handleGoTo = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
editor.commands.scrollToFootnote(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span" style={{ display: "inline" }}>
|
||||
<sup
|
||||
ref={(el) => (anchorRef.current = el)}
|
||||
data-footnote-ref=""
|
||||
data-id={id}
|
||||
className={`${classes.reference} ${selected ? classes.selected : ""}`}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
// The decoration sets --footnote-number; provide a fallback inline.
|
||||
style={{ ["--footnote-number" as any]: `"${number}"` }}
|
||||
aria-label={t("Footnote {{number}}", { number })}
|
||||
role="button"
|
||||
/>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={classes.popover}
|
||||
role="tooltip"
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<div className={classes.popoverHeader}>
|
||||
<span className={classes.popoverNumber}>
|
||||
{t("Footnote {{number}}", { number })}
|
||||
</span>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={handleGoTo}
|
||||
aria-label={t("Go to footnote")}
|
||||
>
|
||||
<IconArrowDown size={16} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<div className={classes.popoverBody}>
|
||||
{defText || t("Empty footnote")}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/* Superscript reference marker. The visible number comes from the numbering
|
||||
plugin decoration which sets the --footnote-number CSS variable. */
|
||||
.reference {
|
||||
cursor: pointer;
|
||||
color: var(--mantine-color-blue-6);
|
||||
font-weight: 500;
|
||||
vertical-align: super;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reference::after {
|
||||
content: var(--footnote-number, "");
|
||||
}
|
||||
|
||||
.reference:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.reference.selected {
|
||||
background-color: var(--mantine-color-blue-1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Read-only popover shown on hover/click of a reference. */
|
||||
.popover {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
max-width: 360px;
|
||||
padding: var(--mantine-spacing-sm);
|
||||
background: var(--mantine-color-body);
|
||||
color: var(--mantine-color-default-color);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.popoverHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.popoverNumber {
|
||||
font-weight: 600;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.popoverBody {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Bottom footnotes container. */
|
||||
.list {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
padding-top: var(--mantine-spacing-md);
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.listHeading {
|
||||
font-weight: 600;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: var(--mantine-color-dimmed);
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.definition {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
/* Tight number→text spacing (~one space) so it reads like "1. text"
|
||||
instead of leaving a wide gap after the period. */
|
||||
gap: 0.4em;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.definitionMarker {
|
||||
flex: 0 0 auto;
|
||||
min-width: 1.5em;
|
||||
/* Right-align within the narrow column so the period sits next to the text
|
||||
and multi-digit numbers (10, 11, …) stay aligned on their right edge. */
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--mantine-color-dimmed);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.definitionContent {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
color: var(--mantine-color-blue-6);
|
||||
user-select: none;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./footnote.module.css";
|
||||
|
||||
/**
|
||||
* NodeView for the bottom footnotes container. Renders a visual separator and a
|
||||
* localized heading, then the editable list of definitions via NodeViewContent.
|
||||
*/
|
||||
export default function FootnotesListView(_props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className={classes.list} contentEditable={false}>
|
||||
<div className={classes.listHeading}>{t("Footnotes")}</div>
|
||||
</div>
|
||||
<NodeViewContent />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -15,39 +15,11 @@ import { useAtomValue } from "jotai";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import classes from "./html-embed-view.module.css";
|
||||
|
||||
/**
|
||||
* Inject raw HTML (including <script> tags) into `container`, executing any
|
||||
* scripts.
|
||||
*
|
||||
* Setting `innerHTML` does NOT run inline or external <script> tags the browser
|
||||
* parses that way: the HTML spec marks scripts inserted via innerHTML as
|
||||
* "already started" so they never execute. To get the tracker/analytics
|
||||
* use-case working we walk the freshly-parsed scripts and replace each with a
|
||||
* brand-new <script> element copying its attributes and inline code. A
|
||||
* programmatically created+inserted <script> DOES execute, so this restores
|
||||
* normal script behaviour in the wiki origin (Variant C).
|
||||
*/
|
||||
function renderRawHtml(container: HTMLElement, source: string) {
|
||||
// Clear any previous render (re-render on source change).
|
||||
container.innerHTML = "";
|
||||
if (!source) return;
|
||||
|
||||
container.innerHTML = source;
|
||||
|
||||
const scripts = Array.from(container.querySelectorAll("script"));
|
||||
for (const oldScript of scripts) {
|
||||
const newScript = document.createElement("script");
|
||||
// Copy every attribute (src, type, async, defer, data-*, etc.).
|
||||
for (const attr of Array.from(oldScript.attributes)) {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
// Copy inline code.
|
||||
newScript.text = oldScript.textContent ?? "";
|
||||
// Replacing the node in place triggers execution.
|
||||
oldScript.parentNode?.replaceChild(newScript, oldScript);
|
||||
}
|
||||
}
|
||||
import {
|
||||
canEdit as computeCanEdit,
|
||||
renderRawHtml,
|
||||
shouldExecute as computeShouldExecute,
|
||||
} from "./render-raw-html.ts";
|
||||
|
||||
export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -70,7 +42,10 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
// here — we execute exactly the `source` the server chose to serve.
|
||||
// - EDITABLE editor (admin authoring): keep gating on the per-workspace
|
||||
// toggle so an admin sees the inert placeholder when the feature is OFF.
|
||||
const shouldExecute = !editor.isEditable || htmlEmbedEnabled;
|
||||
const shouldExecute = computeShouldExecute(
|
||||
editor.isEditable,
|
||||
htmlEmbedEnabled,
|
||||
);
|
||||
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -104,7 +79,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
// The edit affordance is only meaningful in edit mode, is restricted to admins
|
||||
// (the server strips the node for non-admins anyway), and is offered only when
|
||||
// the workspace feature toggle is ON.
|
||||
const canEdit = editor.isEditable && isAdmin && htmlEmbedEnabled;
|
||||
const canEdit = computeCanEdit(editor.isEditable, isAdmin, htmlEmbedEnabled);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { renderRawHtml, shouldExecute, canEdit } from "./render-raw-html";
|
||||
|
||||
// jsdom does NOT execute <script> nodes unless its instance was created with
|
||||
// `runScripts: "dangerously"`. The whole point of renderRawHtml is to make
|
||||
// re-created scripts run, so the execution tests drive a dedicated script-
|
||||
// running JSDOM and pass it a container from THAT document (renderRawHtml uses
|
||||
// `container.ownerDocument`, so it creates the fresh scripts in the running
|
||||
// instance). The default vitest jsdom (no runScripts) is used for the
|
||||
// structural and policy assertions.
|
||||
describe("renderRawHtml (script execution against a runScripts jsdom)", () => {
|
||||
let dom: JSDOM;
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
runScripts: "dangerously",
|
||||
});
|
||||
container = dom.window.document.createElement("div");
|
||||
dom.window.document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
it("re-creates and executes an inline <script> (observable side effect)", () => {
|
||||
renderRawHtml(
|
||||
container,
|
||||
"<div>hello</div><script>window.__htmlEmbedFlag = true;</script>",
|
||||
);
|
||||
// The re-created inline script ran inside the jsdom window.
|
||||
expect((dom.window as unknown as Record<string, unknown>).__htmlEmbedFlag).toBe(
|
||||
true,
|
||||
);
|
||||
// The non-script markup is preserved.
|
||||
expect(container.querySelector("div")?.textContent).toBe("hello");
|
||||
});
|
||||
|
||||
it("copies src/async/defer onto a re-created external <script src>", () => {
|
||||
renderRawHtml(
|
||||
container,
|
||||
'<script src="https://example.com/t.js" async defer></script>',
|
||||
);
|
||||
const script = container.querySelector("script");
|
||||
expect(script).not.toBeNull();
|
||||
expect(script?.getAttribute("src")).toBe("https://example.com/t.js");
|
||||
expect(script?.hasAttribute("async")).toBe(true);
|
||||
expect(script?.hasAttribute("defer")).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the container when the source is empty", () => {
|
||||
container.innerHTML = "<p>stale</p>";
|
||||
renderRawHtml(container, "");
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("clears prior content first on a re-render with new source", () => {
|
||||
const win = dom.window as unknown as Record<string, unknown>;
|
||||
renderRawHtml(
|
||||
container,
|
||||
"<span id='first'>one</span><script>window.__htmlEmbedCount = 1;</script>",
|
||||
);
|
||||
expect(win.__htmlEmbedCount).toBe(1);
|
||||
expect(container.querySelector("#first")).not.toBeNull();
|
||||
|
||||
renderRawHtml(
|
||||
container,
|
||||
"<span id='second'>two</span><script>window.__htmlEmbedCount = 2;</script>",
|
||||
);
|
||||
// Prior content is gone; only the new render remains.
|
||||
expect(container.querySelector("#first")).toBeNull();
|
||||
expect(container.querySelector("#second")).not.toBeNull();
|
||||
expect(win.__htmlEmbedCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldExecute (execution policy)", () => {
|
||||
it("read-only executes regardless of the workspace toggle", () => {
|
||||
// isEditable=false → the server already gated the content.
|
||||
expect(shouldExecute(false, false)).toBe(true);
|
||||
expect(shouldExecute(false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("editable + toggle OFF does NOT execute", () => {
|
||||
expect(shouldExecute(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("editable + toggle ON executes", () => {
|
||||
expect(shouldExecute(true, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEdit (edit policy)", () => {
|
||||
it("a member (non-admin) can never edit", () => {
|
||||
expect(canEdit(true, false, true)).toBe(false);
|
||||
expect(canEdit(false, false, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("an admin with the toggle OFF cannot edit", () => {
|
||||
expect(canEdit(true, true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("an admin with the toggle ON in editable mode can edit", () => {
|
||||
expect(canEdit(true, true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("an admin in read-only mode cannot edit (no edit affordance)", () => {
|
||||
expect(canEdit(false, true, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Pure DOM helpers for the HTML embed node view. Kept out of the React
|
||||
* component so the script re-creation/execution mechanism and the execution/
|
||||
* edit policy can be unit-tested against a bare jsdom container with no
|
||||
* Tiptap/Mantine providers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Inject raw HTML (including <script> tags) into `container`, executing any
|
||||
* scripts.
|
||||
*
|
||||
* Setting `innerHTML` does NOT run inline or external <script> tags the browser
|
||||
* parses that way: the HTML spec marks scripts inserted via innerHTML as
|
||||
* "already started" so they never execute. To get the tracker/analytics
|
||||
* use-case working we walk the freshly-parsed scripts and replace each with a
|
||||
* brand-new <script> element copying its attributes and inline code. A
|
||||
* programmatically created+inserted <script> DOES execute, so this restores
|
||||
* normal script behaviour in the wiki origin (Variant C).
|
||||
*/
|
||||
export function renderRawHtml(container: HTMLElement, source: string): void {
|
||||
// Clear any previous render (re-render on source change).
|
||||
container.innerHTML = "";
|
||||
if (!source) return;
|
||||
|
||||
container.innerHTML = source;
|
||||
|
||||
// Use the container's own document so the helper works against any document
|
||||
// (the live page or a standalone jsdom instance in tests), not just the
|
||||
// ambient global `document`.
|
||||
const doc = container.ownerDocument;
|
||||
const scripts = Array.from(container.querySelectorAll("script"));
|
||||
for (const oldScript of scripts) {
|
||||
const newScript = doc.createElement("script");
|
||||
// Copy every attribute (src, type, async, defer, data-*, etc.).
|
||||
for (const attr of Array.from(oldScript.attributes)) {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
// Copy inline code.
|
||||
newScript.text = oldScript.textContent ?? "";
|
||||
// Replacing the node in place triggers execution.
|
||||
oldScript.parentNode?.replaceChild(newScript, oldScript);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution policy split by editor mode:
|
||||
* - READ-ONLY / public-share view: the SERVER already decided whether to
|
||||
* include the embed (it strips htmlEmbed from shared content when the
|
||||
* workspace toggle is OFF). An anonymous viewer has no workspace and thus
|
||||
* reads `featureEnabled` as false, so we must NOT gate execution on it here
|
||||
* — we execute exactly the `source` the server chose to serve.
|
||||
* - EDITABLE editor (admin authoring): keep gating on the per-workspace toggle
|
||||
* so an admin sees the inert placeholder when the feature is OFF.
|
||||
*/
|
||||
export function shouldExecute(
|
||||
isEditable: boolean,
|
||||
featureEnabled: boolean,
|
||||
): boolean {
|
||||
return !isEditable || featureEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* The edit affordance is only meaningful in edit mode, is restricted to admins
|
||||
* (the server strips the node for non-admins anyway), and is offered only when
|
||||
* the workspace feature toggle is ON.
|
||||
*/
|
||||
export function canEdit(
|
||||
isEditable: boolean,
|
||||
isAdmin: boolean,
|
||||
featureEnabled: boolean,
|
||||
): boolean {
|
||||
return isEditable && isAdmin && featureEnabled;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideEmbedState } from "./decide-embed-state";
|
||||
import { PAGE_EMBED_MAX_DEPTH } from "./page-embed-ancestry-context";
|
||||
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
|
||||
|
||||
const okResult: PageTemplateLookup = {
|
||||
sourcePageId: "p1",
|
||||
slugId: "slug-p1",
|
||||
title: "Template",
|
||||
icon: null,
|
||||
content: { type: "doc" },
|
||||
sourceUpdatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("decideEmbedState", () => {
|
||||
it("returns no_source when sourcePageId is null", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: null,
|
||||
chain: [],
|
||||
hostPageId: null,
|
||||
available: true,
|
||||
result: null,
|
||||
}),
|
||||
).toBe("no_source");
|
||||
});
|
||||
|
||||
it("returns cycle when sourcePageId is already in the ancestor chain", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: ["root", "p1"],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("cycle");
|
||||
});
|
||||
|
||||
it("returns cycle when sourcePageId equals the host page id (top-level self-embed)", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "host",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("cycle");
|
||||
});
|
||||
|
||||
it("returns too_deep when chain length reaches PAGE_EMBED_MAX_DEPTH", () => {
|
||||
const chain = Array.from({ length: PAGE_EMBED_MAX_DEPTH }, (_, i) => `a${i}`);
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain,
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("too_deep");
|
||||
});
|
||||
|
||||
it("cycle wins over too_deep when both apply (cycle checked first)", () => {
|
||||
const chain = Array.from(
|
||||
{ length: PAGE_EMBED_MAX_DEPTH },
|
||||
(_, i) => `a${i}`,
|
||||
);
|
||||
chain[0] = "p1"; // also a cycle
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain,
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("cycle");
|
||||
});
|
||||
|
||||
it("returns unavailable when no lookup context is mounted", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: false,
|
||||
result: null,
|
||||
}),
|
||||
).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("returns loading when available but the result is not back yet", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: null,
|
||||
}),
|
||||
).toBe("loading");
|
||||
});
|
||||
|
||||
it("returns no_access when the result status is no_access", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: { sourcePageId: "p1", status: "no_access" },
|
||||
}),
|
||||
).toBe("no_access");
|
||||
});
|
||||
|
||||
it("returns not_found when the result status is not_found", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: { sourcePageId: "p1", status: "not_found" },
|
||||
}),
|
||||
).toBe("not_found");
|
||||
});
|
||||
|
||||
it("returns ok for a resolved template (happy path)", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("ok");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { PAGE_EMBED_MAX_DEPTH } from "./page-embed-ancestry-context";
|
||||
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
|
||||
|
||||
/**
|
||||
* The render outcome of a single pageEmbed node, decided BEFORE rendering a
|
||||
* nested editor. Kept pure (no React) so the cycle / depth / access / not-found
|
||||
* branch logic is unit-testable in isolation; the node view maps each outcome
|
||||
* to a placeholder or the embedded content.
|
||||
*/
|
||||
export type EmbedState =
|
||||
| "no_source" // no sourcePageId picked yet
|
||||
| "cycle" // self-embed or an ancestor already shows this page
|
||||
| "too_deep" // nesting depth limit reached
|
||||
| "unavailable" // no lookup context (e.g. public share)
|
||||
| "loading" // context present, result not back yet
|
||||
| "ok" // resolved template content to render
|
||||
| "no_access" // server says the viewer can't see the page
|
||||
| "not_found"; // server says the page no longer exists
|
||||
|
||||
export interface DecideEmbedStateInput {
|
||||
sourcePageId: string | null;
|
||||
/** sourcePageIds of every ancestor pageEmbed up the render tree. */
|
||||
chain: string[];
|
||||
/** Host page id; a top-level self-embed must be caught against it. */
|
||||
hostPageId: string | null;
|
||||
/** Whether a lookup context is mounted (false on public shares in MVP). */
|
||||
available: boolean;
|
||||
/** The lookup result, or null while still loading. */
|
||||
result: PageTemplateLookup | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what a pageEmbed should render. The order matters: cycle and depth
|
||||
* guards run first (before any lookup is even consulted), then availability,
|
||||
* then the resolved result. Mirrors the branch ladder in PageEmbedBody.
|
||||
*/
|
||||
export function decideEmbedState({
|
||||
sourcePageId,
|
||||
chain,
|
||||
hostPageId,
|
||||
available,
|
||||
result,
|
||||
}: DecideEmbedStateInput): EmbedState {
|
||||
if (!sourcePageId) return "no_source";
|
||||
|
||||
// Self-embed or a source already present in the ancestor chain → cycle.
|
||||
const isCycle = chain.includes(sourcePageId) || hostPageId === sourcePageId;
|
||||
if (isCycle) return "cycle";
|
||||
|
||||
if (chain.length >= PAGE_EMBED_MAX_DEPTH) return "too_deep";
|
||||
|
||||
if (!available) return "unavailable";
|
||||
if (!result) return "loading";
|
||||
|
||||
if (!("status" in result)) return "ok";
|
||||
if (result.status === "no_access") return "no_access";
|
||||
return "not_found";
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import {
|
||||
PageEmbedAncestryProvider,
|
||||
usePageEmbedAncestry,
|
||||
} from "./page-embed-ancestry-context";
|
||||
|
||||
// Probe child: renders the current ancestry context value as JSON so the test
|
||||
// can assert on the accumulated chain and host without any Tiptap editor.
|
||||
function Probe({ testId }: { testId: string }) {
|
||||
const ancestry = usePageEmbedAncestry();
|
||||
return <div data-testid={testId}>{JSON.stringify(ancestry)}</div>;
|
||||
}
|
||||
|
||||
function read(el: HTMLElement) {
|
||||
return JSON.parse(el.textContent || "{}") as {
|
||||
chain: string[];
|
||||
hostPageId: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
describe("PageEmbedAncestryProvider", () => {
|
||||
it("accumulates the chain in order across nested providers", () => {
|
||||
const { getByTestId } = render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host">
|
||||
<PageEmbedAncestryProvider sourcePageId="b">
|
||||
<PageEmbedAncestryProvider sourcePageId="c">
|
||||
<Probe testId="leaf" />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const value = read(getByTestId("leaf"));
|
||||
expect(value.chain).toEqual(["a", "b", "c"]);
|
||||
expect(value.hostPageId).toBe("host");
|
||||
});
|
||||
|
||||
it("leaves the chain unchanged when sourcePageId is absent, still propagating the host", () => {
|
||||
const { getByTestId } = render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host">
|
||||
<PageEmbedAncestryProvider>
|
||||
<Probe testId="leaf" />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const value = read(getByTestId("leaf"));
|
||||
expect(value.chain).toEqual(["a"]);
|
||||
expect(value.hostPageId).toBe("host");
|
||||
});
|
||||
|
||||
it("keeps the first (top-level) host even if an inner provider passes a different one", () => {
|
||||
const { getByTestId } = render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="top-host">
|
||||
<PageEmbedAncestryProvider sourcePageId="b" hostPageId="inner-host">
|
||||
<Probe testId="leaf" />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const value = read(getByTestId("leaf"));
|
||||
expect(value.chain).toEqual(["a", "b"]);
|
||||
// Inner host is ignored: the top-level host is set once and propagated.
|
||||
expect(value.hostPageId).toBe("top-host");
|
||||
});
|
||||
|
||||
it("defaults to an empty chain and null host with no provider", () => {
|
||||
const { getByTestId } = render(<Probe testId="leaf" />);
|
||||
const value = read(getByTestId("leaf"));
|
||||
expect(value.chain).toEqual([]);
|
||||
expect(value.hostPageId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
|
||||
|
||||
// Mock the API module the provider calls. Hoisted by vitest before the import.
|
||||
const lookupTemplate = vi.fn();
|
||||
vi.mock("@/features/page-embed/services/page-embed-api", () => ({
|
||||
lookupTemplate: (...args: unknown[]) => lookupTemplate(...args),
|
||||
}));
|
||||
|
||||
// Imported AFTER the mock is declared so the provider picks up the mock.
|
||||
import {
|
||||
PageEmbedLookupProvider,
|
||||
usePageEmbedLookup,
|
||||
} from "./page-embed-lookup-context";
|
||||
|
||||
function ok(id: string): PageTemplateLookup {
|
||||
return {
|
||||
sourcePageId: id,
|
||||
slugId: `slug-${id}`,
|
||||
title: `T-${id}`,
|
||||
icon: null,
|
||||
content: { type: "doc" },
|
||||
sourceUpdatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
// Probe that subscribes to a sourceId and exposes its latest result + refresh.
|
||||
function Probe({
|
||||
id,
|
||||
sink,
|
||||
}: {
|
||||
id: string;
|
||||
sink: (api: ReturnType<typeof usePageEmbedLookup>) => void;
|
||||
}) {
|
||||
const api = usePageEmbedLookup(id);
|
||||
sink(api);
|
||||
return <div>{api.result ? "loaded" : "pending"}</div>;
|
||||
}
|
||||
|
||||
describe("PageEmbedLookupProvider (batching / dedup / refresh)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
lookupTemplate.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("dedups two subscribers for the same id into a single lookup call; both get the result", async () => {
|
||||
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
|
||||
let b: ReturnType<typeof usePageEmbedLookup> | null = null;
|
||||
lookupTemplate.mockResolvedValue({ items: [ok("p1")] });
|
||||
|
||||
render(
|
||||
<PageEmbedLookupProvider>
|
||||
<Probe id="p1" sink={(x) => (a = x)} />
|
||||
<Probe id="p1" sink={(x) => (b = x)} />
|
||||
</PageEmbedLookupProvider>,
|
||||
);
|
||||
|
||||
// Subscriptions run in effects + the 10ms debounce batches them together.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
expect(lookupTemplate).toHaveBeenCalledTimes(1);
|
||||
expect(lookupTemplate).toHaveBeenCalledWith({ sourcePageIds: ["p1"] });
|
||||
expect(a!.result).toEqual(ok("p1"));
|
||||
expect(b!.result).toEqual(ok("p1"));
|
||||
});
|
||||
|
||||
it("batches two distinct ids subscribed within the window into one call", async () => {
|
||||
lookupTemplate.mockResolvedValue({ items: [ok("p1"), ok("p2")] });
|
||||
|
||||
render(
|
||||
<PageEmbedLookupProvider>
|
||||
<Probe id="p1" sink={() => {}} />
|
||||
<Probe id="p2" sink={() => {}} />
|
||||
</PageEmbedLookupProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
expect(lookupTemplate).toHaveBeenCalledTimes(1);
|
||||
expect(lookupTemplate.mock.calls[0][0]).toEqual({
|
||||
sourcePageIds: ["p1", "p2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh() clears the cache and re-fetches", async () => {
|
||||
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
|
||||
lookupTemplate.mockResolvedValue({ items: [ok("p1")] });
|
||||
|
||||
render(
|
||||
<PageEmbedLookupProvider>
|
||||
<Probe id="p1" sink={(x) => (a = x)} />
|
||||
</PageEmbedLookupProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
expect(lookupTemplate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// refresh resolves once the next batch flush completes.
|
||||
await act(async () => {
|
||||
const p = a!.refresh();
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await p;
|
||||
});
|
||||
|
||||
expect(lookupTemplate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("a rejected lookup resolves refresh() waiters, clears inFlight, and logs the error (not swallowed)", async () => {
|
||||
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
|
||||
lookupTemplate.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
render(
|
||||
<PageEmbedLookupProvider>
|
||||
<Probe id="p1" sink={(x) => (a = x)} />
|
||||
</PageEmbedLookupProvider>,
|
||||
);
|
||||
|
||||
// Initial subscription enqueues a lookup that rejects.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
// The error message is surfaced, not swallowed.
|
||||
expect(errSpy.mock.calls[0][0]).toContain("[pageEmbed] template lookup failed");
|
||||
|
||||
// inFlight was cleared on failure, so a refresh re-enqueues and resolves.
|
||||
lookupTemplate.mockResolvedValueOnce({ items: [ok("p1")] });
|
||||
let resolved = false;
|
||||
await act(async () => {
|
||||
const p = a!.refresh().then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await p;
|
||||
});
|
||||
expect(resolved).toBe(true);
|
||||
expect(a!.result).toEqual(ok("p1"));
|
||||
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { IconFileText, IconSearch } from "@tabler/icons-react";
|
||||
import type { Editor, Range } from "@tiptap/core";
|
||||
import { searchSuggestions } from "@/features/search/services/search-service";
|
||||
import type { IPage } from "@/features/page/types/page.types";
|
||||
import { buildPickerQuery, excludeHost } from "./page-embed-picker.utils";
|
||||
|
||||
export const PAGE_EMBED_PICKER_EVENT = "open-page-embed-picker";
|
||||
|
||||
@@ -43,21 +44,13 @@ export default function PageEmbedPicker() {
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ["page-embed-template-picker", query],
|
||||
queryFn: () =>
|
||||
searchSuggestions({
|
||||
query,
|
||||
includePages: true,
|
||||
onlyTemplates: true,
|
||||
limit: 20,
|
||||
}),
|
||||
queryFn: () => searchSuggestions(buildPickerQuery(query)),
|
||||
enabled: opened,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
const hostPageId = detailRef.current?.hostPageId;
|
||||
const pages = ((data?.pages ?? []) as IPage[]).filter(
|
||||
(p) => p && p.id !== hostPageId,
|
||||
);
|
||||
const pages = excludeHost((data?.pages ?? []) as IPage[], hostPageId);
|
||||
|
||||
const handleSelect = (page: IPage) => {
|
||||
const detail = detailRef.current;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { excludeHost, buildPickerQuery } from "./page-embed-picker.utils";
|
||||
import type { IPage } from "@/features/page/types/page.types";
|
||||
|
||||
function page(id: string): IPage {
|
||||
return { id, title: id, slugId: `slug-${id}` } as IPage;
|
||||
}
|
||||
|
||||
describe("excludeHost", () => {
|
||||
it("drops the host page from the results (self-embed guard)", () => {
|
||||
const result = excludeHost([page("a"), page("host"), page("b")], "host");
|
||||
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("returns all pages when hostPageId is undefined", () => {
|
||||
const result = excludeHost([page("a"), page("b")], undefined);
|
||||
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("drops null/blank entries", () => {
|
||||
const result = excludeHost(
|
||||
[page("a"), null as unknown as IPage, page("b")],
|
||||
"host",
|
||||
);
|
||||
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPickerQuery", () => {
|
||||
it("passes onlyTemplates:true with the query and page inclusion", () => {
|
||||
expect(buildPickerQuery("foo")).toEqual({
|
||||
query: "foo",
|
||||
includePages: true,
|
||||
onlyTemplates: true,
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an empty query", () => {
|
||||
expect(buildPickerQuery("").query).toBe("");
|
||||
expect(buildPickerQuery("").onlyTemplates).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { IPage } from "@/features/page/types/page.types";
|
||||
import type { SearchSuggestionParams } from "@/features/search/types/search.types";
|
||||
|
||||
/**
|
||||
* Self-embed guard at insertion time: drop the host page (and any null/blank
|
||||
* entries) from the picker results so the current page can't embed itself.
|
||||
*/
|
||||
export function excludeHost(
|
||||
pages: IPage[],
|
||||
hostPageId: string | undefined,
|
||||
): IPage[] {
|
||||
return pages.filter((p) => p && p.id !== hostPageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the search-suggestions query for the template picker. Always restricts
|
||||
* to template-flagged pages (`onlyTemplates`) and includes pages, mirroring the
|
||||
* inline query args in PageEmbedPicker.
|
||||
*/
|
||||
export function buildPickerQuery(query: string): SearchSuggestionParams {
|
||||
return {
|
||||
query,
|
||||
includePages: true,
|
||||
onlyTemplates: true,
|
||||
limit: 20,
|
||||
};
|
||||
}
|
||||
@@ -21,8 +21,8 @@ import { usePageEmbedLookup } from "./page-embed-lookup-context";
|
||||
import {
|
||||
PageEmbedAncestryProvider,
|
||||
usePageEmbedAncestry,
|
||||
PAGE_EMBED_MAX_DEPTH,
|
||||
} from "./page-embed-ancestry-context";
|
||||
import { decideEmbedState } from "./decide-embed-state";
|
||||
import PageEmbedContent from "./page-embed-content";
|
||||
|
||||
function Placeholder({
|
||||
@@ -99,13 +99,15 @@ function PageEmbedBody({
|
||||
}
|
||||
};
|
||||
|
||||
// --- Cycle / depth guard (evaluated before any lookup is rendered) ---------
|
||||
// Self-embed or a source already present in the ancestor chain → cycle.
|
||||
const isCycle =
|
||||
!!sourcePageId &&
|
||||
(ancestry.chain.includes(sourcePageId) ||
|
||||
ancestry.hostPageId === sourcePageId);
|
||||
const isTooDeep = ancestry.chain.length >= PAGE_EMBED_MAX_DEPTH;
|
||||
// --- Cycle / depth / availability decision (pure, unit-tested) ------------
|
||||
// Evaluated before any nested editor is rendered.
|
||||
const embedState = decideEmbedState({
|
||||
sourcePageId,
|
||||
chain: ancestry.chain,
|
||||
hostPageId: ancestry.hostPageId,
|
||||
available,
|
||||
result,
|
||||
});
|
||||
|
||||
const sourceTitle =
|
||||
result && !("status" in result) ? result.title : null;
|
||||
@@ -187,28 +189,28 @@ function PageEmbedBody({
|
||||
) : null;
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (!sourcePageId) {
|
||||
if (embedState === "no_source") {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconInfoCircle size={18} stroke={1.6} />}
|
||||
label={t("No page selected")}
|
||||
/>
|
||||
);
|
||||
} else if (isCycle) {
|
||||
} else if (embedState === "cycle") {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconRepeat size={18} stroke={1.6} />}
|
||||
label={t("Circular embed: this page is already shown above")}
|
||||
/>
|
||||
);
|
||||
} else if (isTooDeep) {
|
||||
} else if (embedState === "too_deep") {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconRepeat size={18} stroke={1.6} />}
|
||||
label={t("Embed nesting limit reached")}
|
||||
/>
|
||||
);
|
||||
} else if (!available) {
|
||||
} else if (embedState === "unavailable") {
|
||||
// No lookup context (e.g. public share) → placeholder, no fetch in MVP.
|
||||
body = (
|
||||
<Placeholder
|
||||
@@ -216,9 +218,9 @@ function PageEmbedBody({
|
||||
label={t("Embedded page is not available here")}
|
||||
/>
|
||||
);
|
||||
} else if (!result) {
|
||||
} else if (embedState === "loading") {
|
||||
body = <div style={{ minHeight: 24 }} />;
|
||||
} else if (!("status" in result)) {
|
||||
} else if (embedState === "ok" && result && !("status" in result)) {
|
||||
body = (
|
||||
<PageEmbedAncestryProvider
|
||||
sourcePageId={sourcePageId}
|
||||
@@ -227,7 +229,7 @@ function PageEmbedBody({
|
||||
<PageEmbedContent content={result.content} />
|
||||
</PageEmbedAncestryProvider>
|
||||
);
|
||||
} else if (result.status === "no_access") {
|
||||
} else if (embedState === "no_access") {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconEyeOff size={18} stroke={1.6} />}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
IconTag,
|
||||
IconMoodSmile,
|
||||
IconRotate2,
|
||||
IconSuperscript,
|
||||
IconArrowsMaximize,
|
||||
} from "@tabler/icons-react";
|
||||
import { PAGE_EMBED_PICKER_EVENT } from "@/features/editor/components/page-embed/page-embed-picker";
|
||||
@@ -368,6 +369,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setDetails().run(),
|
||||
},
|
||||
{
|
||||
title: "Footnote",
|
||||
description: "Insert a footnote reference.",
|
||||
searchTerms: ["footnote", "note", "reference", "сноска", "примечание"],
|
||||
icon: IconSuperscript,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setFootnote().run(),
|
||||
},
|
||||
{
|
||||
title: "Callout",
|
||||
description: "Insert callout notice.",
|
||||
|
||||
@@ -63,6 +63,9 @@ import {
|
||||
TransclusionReference,
|
||||
PageEmbed,
|
||||
TableView,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -94,6 +97,9 @@ import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
|
||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
|
||||
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
|
||||
import FootnoteReferenceView from "@/features/editor/components/footnote/footnote-reference-view.tsx";
|
||||
import FootnotesListView from "@/features/editor/components/footnote/footnotes-list-view.tsx";
|
||||
import FootnoteDefinitionView from "@/features/editor/components/footnote/footnote-definition-view.tsx";
|
||||
import PageEmbedView from "@/features/editor/components/page-embed/page-embed-view.tsx";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
@@ -392,6 +398,19 @@ export const mainExtensions = [
|
||||
TransclusionReference.configure({
|
||||
view: TransclusionReferenceView,
|
||||
}),
|
||||
FootnoteReference.configure({
|
||||
view: FootnoteReferenceView,
|
||||
// Skip orphan-cleanup on remote/collaboration steps so collaborating
|
||||
// clients never fight over footnote integrity (deterministic numbering
|
||||
// decorations handle the rest).
|
||||
isRemoteTransaction: (tr: any) => isChangeOrigin(tr),
|
||||
}),
|
||||
FootnotesList.configure({
|
||||
view: FootnotesListView,
|
||||
}),
|
||||
FootnoteDefinition.configure({
|
||||
view: FootnoteDefinitionView,
|
||||
}),
|
||||
PageEmbed.configure({
|
||||
view: PageEmbedView,
|
||||
}),
|
||||
|
||||
@@ -48,9 +48,16 @@ export default function ReadonlyPageEditor({
|
||||
}, []);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const filteredExtensions = mainExtensions.filter(
|
||||
(ext) => ext.name !== "uniqueID",
|
||||
);
|
||||
const filteredExtensions = mainExtensions
|
||||
.filter((ext) => ext.name !== "uniqueID")
|
||||
// Read-only must only DECORATE footnotes (numbering), never mutate the
|
||||
// doc. Disable the footnote sync/integrity plugin so a programmatic
|
||||
// setContent on a doc the viewer can't edit is never rewritten.
|
||||
.map((ext) =>
|
||||
ext.name === "footnoteReference"
|
||||
? ext.configure({ enableSync: false })
|
||||
: ext,
|
||||
);
|
||||
|
||||
return [
|
||||
...filteredExtensions,
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createRef } from "react";
|
||||
import { render, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||
// SpaceTree pulls in query hooks, page services, i18n, notifications and two
|
||||
// child render components. The expandAll contract is exercised purely through
|
||||
// the imperative ref, so we mock everything that would otherwise need a real
|
||||
// server / router and stub the visual children to empty renders.
|
||||
|
||||
const getSpaceTreeMock = vi.fn();
|
||||
const notificationsShowMock = vi.fn();
|
||||
|
||||
vi.mock("@/features/page/services/page-service.ts", () => ({
|
||||
getSpaceTree: (...args: unknown[]) => getSpaceTreeMock(...args),
|
||||
getPageBreadcrumbs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
// No root pages and no further pages — the data-load effect is inert so the
|
||||
// test fully controls the tree through expandAll.
|
||||
useGetRootSidebarPagesQuery: () => ({
|
||||
data: undefined,
|
||||
hasNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
isFetching: false,
|
||||
}),
|
||||
usePageQuery: () => ({ data: undefined }),
|
||||
fetchAllAncestorChildren: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/tree/hooks/use-tree-mutation.ts", () => ({
|
||||
useTreeMutation: () => ({ handleMove: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (...args: unknown[]) => notificationsShowMock(...args) },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useParams: () => ({ pageSlug: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
extractPageSlugId: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/config.ts", () => ({
|
||||
isCompactPageTreeEnabled: () => false,
|
||||
}));
|
||||
|
||||
// Stub the visual children so we don't drag in the full DnD / Mantine stack.
|
||||
vi.mock("./doc-tree", () => ({
|
||||
DocTree: () => null,
|
||||
ROW_HEIGHT_COMPACT: 28,
|
||||
ROW_HEIGHT_STANDARD: 32,
|
||||
}));
|
||||
vi.mock("./space-tree-row", () => ({
|
||||
SpaceTreeRow: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/core", () => ({
|
||||
Text: ({ children }: { children?: unknown }) => children ?? null,
|
||||
}));
|
||||
|
||||
// The real openTreeNodesAtom is localStorage-backed (atomWithStorage +
|
||||
// getOnInit), which crashes under jsdom's localStorage shim here. Swap in a
|
||||
// plain in-memory atom with the same read value (OpenMap) and the same setter
|
||||
// shape (value OR functional updater) so the component's open-state logic runs
|
||||
// unchanged while staying inside the test store.
|
||||
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
||||
const { atom } = await import("jotai");
|
||||
type OpenMap = Record<string, boolean>;
|
||||
const base = atom<OpenMap>({});
|
||||
const openTreeNodesAtom = atom(
|
||||
(get) => get(base),
|
||||
(get, set, update: OpenMap | ((prev: OpenMap) => OpenMap)) => {
|
||||
const next =
|
||||
typeof update === "function"
|
||||
? (update as (prev: OpenMap) => OpenMap)(get(base))
|
||||
: update;
|
||||
set(base, next);
|
||||
},
|
||||
);
|
||||
return { openTreeNodesAtom };
|
||||
});
|
||||
|
||||
import SpaceTree, { SpaceTreeApi } from "./space-tree";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { openTreeNodesAtom } from "@/features/page/tree/atoms/open-tree-nodes-atom.ts";
|
||||
import { createStore, Provider } from "jotai";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
// A flat space-tree response (parentPageId pointers) that buildTree +
|
||||
// buildTreeWithChildren nest into a multi-level tree. Depth > 1 lets us assert
|
||||
// expandAll never fans out into per-branch fetches (no N+1).
|
||||
function spaceTreeItems(): SpaceTreeNode[] {
|
||||
const n = (
|
||||
id: string,
|
||||
parentPageId: string | null,
|
||||
position: string,
|
||||
): SpaceTreeNode => ({
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id,
|
||||
icon: undefined,
|
||||
position,
|
||||
spaceId: "space-1",
|
||||
parentPageId: parentPageId as unknown as string,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
});
|
||||
return [
|
||||
n("root", null, "a0"),
|
||||
n("branch", "root", "a1"),
|
||||
n("leaf", "branch", "a1"),
|
||||
];
|
||||
}
|
||||
|
||||
function renderTree(store: ReturnType<typeof createStore>) {
|
||||
const ref = createRef<SpaceTreeApi>();
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<SpaceTree ref={ref} spaceId="space-1" readOnly={false} />
|
||||
</Provider>,
|
||||
);
|
||||
return ref;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getSpaceTreeMock.mockReset();
|
||||
notificationsShowMock.mockReset();
|
||||
// jsdom's localStorage shim here lacks `clear`; guard it. Each test uses a
|
||||
// fresh jotai store anyway, so cross-test open-state never leaks.
|
||||
try {
|
||||
localStorage.clear?.();
|
||||
} catch {
|
||||
/* ignore — fresh store per test isolates state */
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("SpaceTree.expandAll (integration via ref)", () => {
|
||||
it("makes exactly ONE getSpaceTree call regardless of depth (no N+1)", async () => {
|
||||
getSpaceTreeMock.mockResolvedValue(spaceTreeItems());
|
||||
const store = createStore();
|
||||
const ref = renderTree(store);
|
||||
|
||||
await ref.current!.expandAll();
|
||||
|
||||
expect(getSpaceTreeMock).toHaveBeenCalledTimes(1);
|
||||
expect(getSpaceTreeMock).toHaveBeenCalledWith({ spaceId: "space-1" });
|
||||
|
||||
// Every branch node (root, branch) is opened; the leaf needs no entry.
|
||||
const openMap = store.get(openTreeNodesAtom);
|
||||
expect(openMap["root"]).toBe(true);
|
||||
expect(openMap["branch"]).toBe(true);
|
||||
expect(openMap["leaf"]).toBeUndefined();
|
||||
|
||||
// The full tree replaced the current-space nodes.
|
||||
const data = store.get(treeDataAtom);
|
||||
expect(data.map((d) => d.id)).toEqual(["root"]);
|
||||
});
|
||||
|
||||
it("shows a notification and still resets isExpanding when getSpaceTree rejects", async () => {
|
||||
getSpaceTreeMock.mockRejectedValue(new Error("boom"));
|
||||
const store = createStore();
|
||||
const ref = renderTree(store);
|
||||
|
||||
await ref.current!.expandAll();
|
||||
|
||||
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: "red" }),
|
||||
);
|
||||
|
||||
// isExpanding must be reset in the finally block even on failure.
|
||||
await waitFor(() => {
|
||||
expect(ref.current!.isExpanding).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts the merge when the space switches mid-flight", async () => {
|
||||
// getSpaceTree resolves only after we flip the tree to a different space,
|
||||
// simulating the user navigating away while the request is in flight.
|
||||
let resolveTree: (v: SpaceTreeNode[]) => void = () => {};
|
||||
getSpaceTreeMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<SpaceTreeNode[]>((resolve) => {
|
||||
resolveTree = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const store = createStore();
|
||||
const ref = createRef<SpaceTreeApi>();
|
||||
const { rerender } = render(
|
||||
<Provider store={store}>
|
||||
<SpaceTree ref={ref} spaceId="space-1" readOnly={false} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const promise = ref.current!.expandAll();
|
||||
|
||||
// Switch the space mid-flight: spaceIdRef.current becomes "space-2".
|
||||
rerender(
|
||||
<Provider store={store}>
|
||||
<SpaceTree ref={ref} spaceId="space-2" readOnly={false} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Now resolve the in-flight request for the OLD space.
|
||||
resolveTree(spaceTreeItems());
|
||||
await promise;
|
||||
|
||||
// The merge must have been aborted: no tree data written, no branches opened.
|
||||
expect(store.get(treeDataAtom)).toEqual([]);
|
||||
const openMap = store.get(openTreeNodesAtom);
|
||||
expect(openMap["root"]).toBeUndefined();
|
||||
expect(openMap["branch"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
mergeRootTrees,
|
||||
collectAllIds,
|
||||
collectBranchIds,
|
||||
openBranches,
|
||||
closeIds,
|
||||
} from "@/features/page/tree/utils/utils.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
@@ -236,11 +238,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
// Open every branch node (node with children) of the current space only.
|
||||
const branchIds = collectBranchIds(fullTree);
|
||||
|
||||
setOpenTreeNodes((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of branchIds) next[id] = true;
|
||||
return next;
|
||||
});
|
||||
setOpenTreeNodes((prev) => openBranches(prev, branchIds));
|
||||
} catch (err: any) {
|
||||
// Never swallow: log full error + surface the real reason.
|
||||
console.error("[tree] expandAll failed", err);
|
||||
@@ -261,11 +259,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
// other spaces' expanded state is left intact.
|
||||
const ids = collectAllIds(filteredData);
|
||||
|
||||
setOpenTreeNodes((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of ids) next[id] = false;
|
||||
return next;
|
||||
});
|
||||
setOpenTreeNodes((prev) => closeIds(prev, ids));
|
||||
}, [filteredData, setOpenTreeNodes]);
|
||||
|
||||
useImperativeHandle(
|
||||
|
||||
@@ -196,6 +196,17 @@ describe('treeModel.insertByPosition', () => {
|
||||
const t = treeModel.insertByPosition(roots, null, node);
|
||||
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'c', 'x']);
|
||||
});
|
||||
|
||||
it('tie-break: a node whose position EQUALS a sibling lands deterministically (strict >)', () => {
|
||||
// The insertion index is the first sibling whose position sorts STRICTLY
|
||||
// after the new node's. An equal sibling is not strictly after, so it is
|
||||
// skipped — the new node lands immediately AFTER every equal-position
|
||||
// sibling and before the first strictly-greater one. This is deterministic:
|
||||
// a tie always resolves the same way on every client.
|
||||
const node: P = { id: 'x', name: 'X', position: 'a2' }; // equals b's position
|
||||
const t = treeModel.insertByPosition(roots, null, node);
|
||||
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'x', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
// addTreeNode idempotency: the receiver early-returns when the node id already
|
||||
@@ -692,4 +703,45 @@ describe('treeModel.move', () => {
|
||||
});
|
||||
expect(out.tree).toBe(fixture);
|
||||
});
|
||||
|
||||
it('cross-parent move does NOT apply the same-parent adjust (no off-by-one)', () => {
|
||||
// Source `x3` sits at index 2 in parent `x`; target `y1` sits at index 0 in
|
||||
// parent `y`. sourceInfo.index (2) > info.index (0) AND the parents differ,
|
||||
// so the `sameParent && source.index < info.index` adjust must be 0 — the
|
||||
// node must land at index 0 in `y`, not at index -1 (which would silently
|
||||
// drop it at a wrong slot / off-by-one).
|
||||
const crossFixture: N[] = [
|
||||
{
|
||||
id: 'x',
|
||||
name: 'X',
|
||||
children: [
|
||||
{ id: 'x1', name: 'X1' },
|
||||
{ id: 'x2', name: 'X2' },
|
||||
{ id: 'x3', name: 'X3' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'y',
|
||||
name: 'Y',
|
||||
children: [
|
||||
{ id: 'y1', name: 'Y1' },
|
||||
{ id: 'y2', name: 'Y2' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const { tree: t, result } = treeModel.move(crossFixture, 'x3', {
|
||||
kind: 'reorder-before',
|
||||
targetId: 'y1',
|
||||
});
|
||||
expect(result).toEqual({ parentId: 'y', index: 0 });
|
||||
expect(treeModel.find(t, 'y')?.children?.map((n) => n.id)).toEqual([
|
||||
'x3',
|
||||
'y1',
|
||||
'y2',
|
||||
]);
|
||||
expect(treeModel.find(t, 'x')?.children?.map((n) => n.id)).toEqual([
|
||||
'x1',
|
||||
'x2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildTree } from "./utils";
|
||||
import {
|
||||
buildTree,
|
||||
buildTreeWithChildren,
|
||||
collectAllIds,
|
||||
collectBranchIds,
|
||||
openBranches,
|
||||
closeIds,
|
||||
} from "./utils";
|
||||
import type { IPage } from "@/features/page/types/page.types.ts";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
function page(id: string, position: string): IPage {
|
||||
return {
|
||||
@@ -15,6 +23,44 @@ function page(id: string, position: string): IPage {
|
||||
} as IPage;
|
||||
}
|
||||
|
||||
// Flat SpaceTreeNode factory for buildTreeWithChildren (it consumes a flat list
|
||||
// with parentPageId pointers and nests them).
|
||||
function flatNode(
|
||||
id: string,
|
||||
parentPageId: string | null,
|
||||
position: string,
|
||||
): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id.toUpperCase(),
|
||||
icon: undefined,
|
||||
position,
|
||||
spaceId: "space-1",
|
||||
parentPageId: parentPageId as unknown as string,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Nested SpaceTreeNode factory for collectAllIds / collectBranchIds.
|
||||
function treeNode(
|
||||
id: string,
|
||||
children: SpaceTreeNode[] = [],
|
||||
): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id.toUpperCase(),
|
||||
icon: undefined,
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
hasChildren: children.length > 0,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildTree", () => {
|
||||
it("builds one node per unique page", () => {
|
||||
const tree = buildTree([page("a", "a1"), page("b", "a2")]);
|
||||
@@ -38,3 +84,192 @@ describe("buildTree", () => {
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectBranchIds", () => {
|
||||
it("returns every node-with-children id in a multi-level tree", () => {
|
||||
const tree = [
|
||||
treeNode("root", [
|
||||
treeNode("branch1", [treeNode("leaf1")]),
|
||||
treeNode("leaf2"),
|
||||
]),
|
||||
treeNode("root2", [treeNode("leaf3")]),
|
||||
];
|
||||
expect(collectBranchIds(tree).sort()).toEqual([
|
||||
"branch1",
|
||||
"root",
|
||||
"root2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] for a leaf-only tree", () => {
|
||||
const tree = [treeNode("a"), treeNode("b"), treeNode("c")];
|
||||
expect(collectBranchIds(tree)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT include a node whose children is an empty array", () => {
|
||||
// hasChildren-less / empty-children nodes are leaves for expansion purposes.
|
||||
const tree = [treeNode("a", [])];
|
||||
expect(collectBranchIds(tree)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns every ancestor id in a deep single chain", () => {
|
||||
const chain = treeNode("a", [
|
||||
treeNode("b", [treeNode("c", [treeNode("d")])]),
|
||||
]);
|
||||
// a, b, c are branches; d is the leaf.
|
||||
expect(collectBranchIds([chain])).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("returns [] for an empty tree", () => {
|
||||
expect(collectBranchIds([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectAllIds", () => {
|
||||
it("returns every id (roots, branches, leaves)", () => {
|
||||
const tree = [
|
||||
treeNode("root", [
|
||||
treeNode("branch1", [treeNode("leaf1")]),
|
||||
treeNode("leaf2"),
|
||||
]),
|
||||
treeNode("root2"),
|
||||
];
|
||||
expect(collectAllIds(tree).sort()).toEqual([
|
||||
"branch1",
|
||||
"leaf1",
|
||||
"leaf2",
|
||||
"root",
|
||||
"root2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns every id in a deep chain", () => {
|
||||
const chain = treeNode("a", [
|
||||
treeNode("b", [treeNode("c", [treeNode("d")])]),
|
||||
]);
|
||||
expect(collectAllIds([chain])).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("returns [] for an empty tree", () => {
|
||||
expect(collectAllIds([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("is a superset of collectBranchIds for the same tree (property)", () => {
|
||||
const tree = [
|
||||
treeNode("root", [
|
||||
treeNode("branch1", [treeNode("leaf1"), treeNode("leaf2")]),
|
||||
treeNode("branch2", [treeNode("leaf3")]),
|
||||
treeNode("leaf4"),
|
||||
]),
|
||||
treeNode("root2", [treeNode("leaf5")]),
|
||||
];
|
||||
const all = new Set(collectAllIds(tree));
|
||||
const branches = collectBranchIds(tree);
|
||||
for (const id of branches) {
|
||||
expect(all.has(id)).toBe(true);
|
||||
}
|
||||
// And the superset is strictly larger (it also has the leaves).
|
||||
expect(all.size).toBeGreaterThan(branches.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTreeWithChildren", () => {
|
||||
it("nests a flat list and sorts siblings by position", () => {
|
||||
// Provided out of position order to prove the sort.
|
||||
const flat = [
|
||||
flatNode("root", null, "a0"),
|
||||
flatNode("c2", "root", "a4"),
|
||||
flatNode("c1", "root", "a1"),
|
||||
];
|
||||
const tree = buildTreeWithChildren(flat);
|
||||
expect(tree.map((n) => n.id)).toEqual(["root"]);
|
||||
expect(tree[0].children.map((n) => n.id)).toEqual(["c1", "c2"]);
|
||||
});
|
||||
|
||||
it("recomputes hasChildren to true for nodes that gain children", () => {
|
||||
// Parent ships with hasChildren=false; building must flip it true.
|
||||
const flat = [
|
||||
flatNode("root", null, "a0"),
|
||||
flatNode("child", "root", "a1"),
|
||||
];
|
||||
expect(flat[0].hasChildren).toBe(false);
|
||||
const tree = buildTreeWithChildren(flat);
|
||||
expect(tree[0].hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a node whose parentPageId is ABSENT from the list as a root (no crash)", () => {
|
||||
// Permission-trimmed response: `orphan`'s parent `missing` was filtered out
|
||||
// server-side. The function must not throw and must surface the orphan as a
|
||||
// root rather than dropping or crashing on it.
|
||||
const flat = [
|
||||
flatNode("root", null, "a0"),
|
||||
flatNode("orphan", "missing", "a2"),
|
||||
];
|
||||
let tree: SpaceTreeNode[] = [];
|
||||
expect(() => {
|
||||
tree = buildTreeWithChildren(flat);
|
||||
}).not.toThrow();
|
||||
expect(tree.map((n) => n.id).sort()).toEqual(["orphan", "root"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openBranches", () => {
|
||||
it("sets all given ids to true", () => {
|
||||
const next = openBranches({}, ["a", "b", "c"]);
|
||||
expect(next).toEqual({ a: true, b: true, c: true });
|
||||
});
|
||||
|
||||
it("preserves pre-existing open ids and other-space ids", () => {
|
||||
const prev = { existing: true, "other-space": true, closed: false };
|
||||
const next = openBranches(prev, ["a"]);
|
||||
expect(next).toEqual({
|
||||
existing: true,
|
||||
"other-space": true,
|
||||
closed: false,
|
||||
a: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the input map", () => {
|
||||
const prev = { a: false };
|
||||
const next = openBranches(prev, ["a"]);
|
||||
expect(prev).toEqual({ a: false });
|
||||
expect(next).not.toBe(prev);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const once = openBranches({ z: true }, ["a", "b"]);
|
||||
const twice = openBranches(once, ["a", "b"]);
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeIds", () => {
|
||||
it("flips current-space ids to false while leaving OTHER-space ids untouched", () => {
|
||||
const prev = {
|
||||
"current-1": true,
|
||||
"current-2": true,
|
||||
"other-space": true,
|
||||
};
|
||||
const next = closeIds(prev, ["current-1", "current-2"]);
|
||||
expect(next).toEqual({
|
||||
"current-1": false,
|
||||
"current-2": false,
|
||||
"other-space": true, // untouched
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the input map", () => {
|
||||
const prev = { a: true };
|
||||
const next = closeIds(prev, ["a"]);
|
||||
expect(prev).toEqual({ a: true });
|
||||
expect(next).not.toBe(prev);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const once = closeIds({ keep: true }, ["a", "b"]);
|
||||
const twice = closeIds(once, ["a", "b"]);
|
||||
expect(twice).toEqual(once);
|
||||
expect(twice).toEqual({ keep: true, a: false, b: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,11 +142,17 @@ export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
|
||||
// Build the tree array
|
||||
items.forEach((item) => {
|
||||
const node = nodeMap[item.id];
|
||||
if (item.parentPageId !== null) {
|
||||
// A permission-trimmed response can include a node whose `parentPageId` is
|
||||
// not in the list (the parent was filtered out server-side). Treat such an
|
||||
// orphan as a root instead of dereferencing an absent parent and throwing
|
||||
// "Cannot read properties of undefined". Happy-path behaviour is unchanged:
|
||||
// a node whose parent IS present still nests under it.
|
||||
if (item.parentPageId !== null && nodeMap[item.parentPageId]) {
|
||||
// Find the parent node and add the current node to its children
|
||||
nodeMap[item.parentPageId].children.push(node);
|
||||
} else {
|
||||
// If the item has no parent, it's a root node, so add it to the result array
|
||||
// If the item has no parent (or its parent isn't loaded), it's a root
|
||||
// node, so add it to the result array.
|
||||
result.push(node);
|
||||
}
|
||||
});
|
||||
@@ -254,3 +260,30 @@ export function collectBranchIds(nodes: SpaceTreeNode[]): string[] {
|
||||
walk(nodes);
|
||||
return ids;
|
||||
}
|
||||
|
||||
// The open-state map (`openTreeNodesAtom`) is shared across spaces. Pure
|
||||
// next-map helpers for expand/collapse so the merge logic can be unit-tested
|
||||
// without rendering SpaceTree. Both return a fresh map and never mutate the
|
||||
// input — ids not in `ids` (e.g. other spaces) are carried over untouched.
|
||||
|
||||
// Set each id in `ids` to true (open). Pre-existing entries (including other
|
||||
// spaces' open state) are preserved.
|
||||
export function openBranches(
|
||||
prevMap: Record<string, boolean>,
|
||||
ids: string[],
|
||||
): Record<string, boolean> {
|
||||
const next = { ...prevMap };
|
||||
for (const id of ids) next[id] = true;
|
||||
return next;
|
||||
}
|
||||
|
||||
// Set each id in `ids` to false (closed). Entries not listed (e.g. other
|
||||
// spaces' ids) are left exactly as they were.
|
||||
export function closeIds(
|
||||
prevMap: Record<string, boolean>,
|
||||
ids: string[],
|
||||
): Record<string, boolean> {
|
||||
const next = { ...prevMap };
|
||||
for (const id of ids) next[id] = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
Box,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
@@ -22,6 +20,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChat, type UIMessage } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import MessageList from "@/features/ai-chat/components/message-list.tsx";
|
||||
|
||||
interface ShareAiWidgetProps {
|
||||
/** The share id (or key) the assistant is scoped to. */
|
||||
@@ -30,17 +29,6 @@ interface ShareAiWidgetProps {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
/** Concatenate the visible text parts of a UIMessage. */
|
||||
function messageText(message: UIMessage): string {
|
||||
return (message.parts ?? [])
|
||||
.filter(
|
||||
(p): p is { type: "text"; text: string } =>
|
||||
p?.type === "text" && typeof (p as { text?: string }).text === "string",
|
||||
)
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight, EPHEMERAL "Ask AI" widget for a public shared page.
|
||||
*
|
||||
@@ -49,6 +37,15 @@ function messageText(message: UIMessage): string {
|
||||
* memory (this component's `useChat` store) and is sent with `credentials:
|
||||
* "omit"` to the anonymous `/api/shares/ai/stream` endpoint. The server stores
|
||||
* nothing.
|
||||
*
|
||||
* Presentation is now shared with the internal chat: the same `MessageList`
|
||||
* renders the streamed transcript, so the public share gets the SAME
|
||||
* incremental markdown render, animated typing indicator, and tool-call cards
|
||||
* as the internal chat. Only the anonymous specifics differ — no auth, no
|
||||
* history, `credentials: "omit"`, suppressed page citations (an anonymous
|
||||
* reader cannot open the linked internal pages), neutralized internal markdown
|
||||
* links (so internal UUIDs/auth-gated routes in the answer don't leak as
|
||||
* clickable links), and a documentation-focused empty state.
|
||||
*/
|
||||
export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -147,53 +144,39 @@ export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) {
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} p="sm" scrollbarSize={6} type="scroll">
|
||||
{messages.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" mt="lg">
|
||||
{t("Ask a question about this documentation.")}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{messages.map((message) => (
|
||||
<Box
|
||||
key={message.id}
|
||||
style={{
|
||||
alignSelf:
|
||||
message.role === "user" ? "flex-end" : "flex-start",
|
||||
maxWidth: "85%",
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
p="xs"
|
||||
radius="md"
|
||||
bg={
|
||||
message.role === "user"
|
||||
? "var(--mantine-color-blue-light)"
|
||||
: "var(--mantine-color-default-hover)"
|
||||
}
|
||||
>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{messageText(message) ||
|
||||
(isStreaming ? t("Thinking…") : "")}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{/* Shared transcript: same incremental streaming render, animated typing
|
||||
indicator, markdown, and tool-call cards as the internal chat. The
|
||||
share is anonymous, so page citation links are suppressed (an
|
||||
anonymous reader cannot open the linked internal pages). */}
|
||||
<Box style={{ flex: 1, minHeight: 0, display: "flex", padding: "var(--mantine-spacing-sm)" }}>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
showCitations={false}
|
||||
// Anonymous reader: neutralize internal/relative links in the
|
||||
// assistant's markdown so internal UUIDs/auth-gated routes don't
|
||||
// leak as clickable links (external http(s) links are kept).
|
||||
neutralizeInternalLinks={true}
|
||||
emptyState={
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("Ask a question about this documentation.")}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
mt="sm"
|
||||
title={t("Something went wrong")}
|
||||
>
|
||||
{t("The assistant is unavailable right now. Please try again.")}
|
||||
</Alert>
|
||||
)}
|
||||
</ScrollArea>
|
||||
{error && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
mx="sm"
|
||||
mb="xs"
|
||||
title={t("Something went wrong")}
|
||||
>
|
||||
{t("The assistant is unavailable right now. Please try again.")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group
|
||||
gap="xs"
|
||||
|
||||
264
apps/client/src/features/websocket/tree-socket-reducers.test.ts
Normal file
264
apps/client/src/features/websocket/tree-socket-reducers.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
applyAddTreeNode,
|
||||
applyMoveTreeNode,
|
||||
applyDeleteTreeNode,
|
||||
} from "./tree-socket-reducers";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
// Minimal node factory — fills the SpaceTreeNode shape required fields while
|
||||
// letting tests override the bits that matter (position, parentPageId, etc).
|
||||
function node(
|
||||
id: string,
|
||||
overrides: Partial<SpaceTreeNode> = {},
|
||||
): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id.toUpperCase(),
|
||||
icon: undefined,
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyMoveTreeNode", () => {
|
||||
// Destination parent `dst` is loaded with three positioned children; the moved
|
||||
// node `src` is a sibling at root with a later position.
|
||||
const buildTree = (): SpaceTreeNode[] => [
|
||||
node("dst", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [
|
||||
node("c1", { position: "a1", parentPageId: "dst" }),
|
||||
node("c2", { position: "a3", parentPageId: "dst" }),
|
||||
node("c3", { position: "a5", parentPageId: "dst" }),
|
||||
],
|
||||
}),
|
||||
node("src", { position: "a9" }),
|
||||
];
|
||||
|
||||
it("places the node by position in the MIDDLE slot of the destination", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "src",
|
||||
parentId: "dst",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a4",
|
||||
pageData: {},
|
||||
});
|
||||
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
|
||||
"c1",
|
||||
"c2",
|
||||
"src",
|
||||
"c3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to REMOVING the node when destination parent is not loaded (no leak)", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "src",
|
||||
parentId: "not-loaded",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a4",
|
||||
pageData: {},
|
||||
});
|
||||
// The source must not linger at its old place — it is removed entirely.
|
||||
expect(treeModel.find(next, "src")).toBeNull();
|
||||
// Destination children are untouched.
|
||||
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
|
||||
"c1",
|
||||
"c2",
|
||||
"c3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("flips the OLD parent's hasChildren to false when it is left childless", () => {
|
||||
// src is the only child of `old`; moving it to `dst` empties `old`.
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("old", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [node("src", { position: "a1", parentPageId: "old" })],
|
||||
}),
|
||||
node("dst", { position: "a2", hasChildren: false }),
|
||||
];
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "src",
|
||||
parentId: "dst",
|
||||
oldParentId: "old",
|
||||
index: 0,
|
||||
position: "a1",
|
||||
pageData: {},
|
||||
});
|
||||
expect(treeModel.find(next, "old")?.hasChildren).toBe(false);
|
||||
});
|
||||
|
||||
it("flips the NEW parent's hasChildren to true", () => {
|
||||
// dst starts as a childless leaf; moving src into it must flip the chevron.
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("dst", { position: "a0", hasChildren: false }),
|
||||
node("src", { position: "a9" }),
|
||||
];
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "src",
|
||||
parentId: "dst",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a1",
|
||||
pageData: {},
|
||||
});
|
||||
expect(treeModel.find(next, "dst")?.hasChildren).toBe(true);
|
||||
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
|
||||
"src",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns prev unchanged when the source node is not found", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "ghost",
|
||||
parentId: "dst",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a4",
|
||||
pageData: {},
|
||||
});
|
||||
expect(next).toBe(tree);
|
||||
});
|
||||
|
||||
it("applies authoritative pageData (title/icon/hasChildren) to the moved node", () => {
|
||||
const tree = buildTree();
|
||||
const next = applyMoveTreeNode(tree, {
|
||||
id: "src",
|
||||
parentId: "dst",
|
||||
oldParentId: null,
|
||||
index: 0,
|
||||
position: "a4",
|
||||
pageData: { title: "Renamed", icon: "fire", hasChildren: true },
|
||||
});
|
||||
const moved = treeModel.find(next, "src");
|
||||
expect(moved?.name).toBe("Renamed");
|
||||
expect(moved?.icon).toBe("fire");
|
||||
expect(moved?.hasChildren).toBe(true);
|
||||
expect(moved?.position).toBe("a4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDeleteTreeNode", () => {
|
||||
it("removes the node together with its descendants", () => {
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("p", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [
|
||||
node("child", {
|
||||
position: "a1",
|
||||
parentPageId: "p",
|
||||
hasChildren: true,
|
||||
children: [node("grandchild", { position: "a1", parentPageId: "child" })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
const next = applyDeleteTreeNode(tree, {
|
||||
node: node("child", { parentPageId: "p" }),
|
||||
});
|
||||
expect(treeModel.find(next, "child")).toBeNull();
|
||||
expect(treeModel.find(next, "grandchild")).toBeNull();
|
||||
expect(treeModel.find(next, "p")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns prev unchanged when the node is already gone (idempotent)", () => {
|
||||
const tree: SpaceTreeNode[] = [node("a", { position: "a0" })];
|
||||
const next = applyDeleteTreeNode(tree, {
|
||||
node: node("ghost"),
|
||||
});
|
||||
expect(next).toBe(tree);
|
||||
});
|
||||
|
||||
it("flips the parent's hasChildren to false when it is left childless", () => {
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("p", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [node("only", { position: "a1", parentPageId: "p" })],
|
||||
}),
|
||||
];
|
||||
const next = applyDeleteTreeNode(tree, {
|
||||
node: node("only", { parentPageId: "p" }),
|
||||
});
|
||||
expect(treeModel.find(next, "p")?.hasChildren).toBe(false);
|
||||
expect(treeModel.find(next, "p")?.children).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves the parent's hasChildren true when other children remain", () => {
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("p", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [
|
||||
node("c1", { position: "a1", parentPageId: "p" }),
|
||||
node("c2", { position: "a2", parentPageId: "p" }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
const next = applyDeleteTreeNode(tree, {
|
||||
node: node("c1", { parentPageId: "p" }),
|
||||
});
|
||||
expect(treeModel.find(next, "p")?.hasChildren).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAddTreeNode", () => {
|
||||
const roots = (): SpaceTreeNode[] => [
|
||||
node("a", { position: "a0" }),
|
||||
node("b", { position: "a2" }),
|
||||
node("c", { position: "a4" }),
|
||||
];
|
||||
|
||||
it("inserts the new node by position among siblings", () => {
|
||||
const tree = roots();
|
||||
const next = applyAddTreeNode(tree, {
|
||||
parentId: null as unknown as string,
|
||||
index: 0,
|
||||
data: node("x", { position: "a3" }),
|
||||
});
|
||||
expect(next.map((n) => n.id)).toEqual(["a", "b", "x", "c"]);
|
||||
});
|
||||
|
||||
it("returns prev unchanged when the id is already present (idempotent)", () => {
|
||||
const tree = roots();
|
||||
const next = applyAddTreeNode(tree, {
|
||||
parentId: null as unknown as string,
|
||||
index: 0,
|
||||
data: node("b", { position: "a9" }),
|
||||
});
|
||||
expect(next).toBe(tree);
|
||||
expect(next.map((n) => n.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("flips the new parent's hasChildren to true", () => {
|
||||
// Parent `p` is a childless leaf; adding a child must flip its chevron.
|
||||
const tree: SpaceTreeNode[] = [
|
||||
node("p", { position: "a0", hasChildren: false }),
|
||||
];
|
||||
const next = applyAddTreeNode(tree, {
|
||||
parentId: "p",
|
||||
index: 0,
|
||||
data: node("child", { position: "a1", parentPageId: "p" }),
|
||||
});
|
||||
expect(treeModel.find(next, "p")?.hasChildren).toBe(true);
|
||||
expect(treeModel.find(next, "p")?.children?.map((n) => n.id)).toEqual([
|
||||
"child",
|
||||
]);
|
||||
});
|
||||
});
|
||||
164
apps/client/src/features/websocket/tree-socket-reducers.ts
Normal file
164
apps/client/src/features/websocket/tree-socket-reducers.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import type {
|
||||
AddTreeNodeEvent,
|
||||
MoveTreeNodeEvent,
|
||||
DeleteTreeNodeEvent,
|
||||
UpdateEvent,
|
||||
} from "@/features/websocket/types";
|
||||
|
||||
// Pure tree transforms for the `useTreeSocket` reducer arms. Extracted from the
|
||||
// hook so the realtime tree behaviour can be unit-tested without rendering the
|
||||
// hook, the socket, or jotai. The hook calls these inside its `setData`.
|
||||
//
|
||||
// IMPORTANT: these are PURE — no `queryClient`, no notifications, no atoms. The
|
||||
// delete arm's `queryClient.invalidateQueries` side effect stays in the hook;
|
||||
// `applyDeleteTreeNode` is a pure tree transform only.
|
||||
|
||||
// `updateOne` for a page: patch the in-tree node's name/icon from the payload.
|
||||
// No-op (returns the same reference) when the node isn't loaded on this client.
|
||||
export function applyUpdateOne(
|
||||
prev: SpaceTreeNode[],
|
||||
event: UpdateEvent,
|
||||
): SpaceTreeNode[] {
|
||||
if (!treeModel.find(prev, event.id)) return prev;
|
||||
let next = prev;
|
||||
if (event.payload?.title !== undefined) {
|
||||
next = treeModel.update(next, event.id, {
|
||||
name: event.payload.title,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
if (event.payload?.icon !== undefined) {
|
||||
next = treeModel.update(next, event.id, {
|
||||
icon: event.payload.icon,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// `addTreeNode`: insert the new node by its fractional `position` among the
|
||||
// already-loaded siblings (not the sender's absolute index). Idempotent — if the
|
||||
// id already exists (optimistic author insert or re-delivery) returns prev
|
||||
// unchanged. Flips the new parent's `hasChildren` to true so the chevron renders.
|
||||
export function applyAddTreeNode(
|
||||
prev: SpaceTreeNode[],
|
||||
payload: AddTreeNodeEvent["payload"],
|
||||
): SpaceTreeNode[] {
|
||||
// Idempotent: the author already inserted the node optimistically, and a node
|
||||
// may be re-delivered — never insert a duplicate id.
|
||||
if (treeModel.find(prev, payload.data.id)) return prev;
|
||||
const newParentId = payload.parentId as string | null;
|
||||
// Insert by `position` among already-loaded siblings (not the sender's
|
||||
// absolute index) so order is consistent across clients with different loaded
|
||||
// sets.
|
||||
let next = treeModel.insertByPosition(prev, newParentId, payload.data);
|
||||
// Mirror the emitter: flip new parent's hasChildren to true so the chevron
|
||||
// renders on the receiver.
|
||||
if (newParentId) {
|
||||
next = treeModel.update(next, newParentId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// `moveTreeNode`: place the moved node by its fractional `position` among the new
|
||||
// siblings (NOT the sender's absolute index). If the destination parent isn't
|
||||
// loaded on this client, fall back to removing the source so the UI stays
|
||||
// consistent. Applies authoritative `pageData` fields and mirrors the
|
||||
// `hasChildren` bookkeeping for both the old and the new parent.
|
||||
export function applyMoveTreeNode(
|
||||
prev: SpaceTreeNode[],
|
||||
payload: MoveTreeNodeEvent["payload"],
|
||||
): SpaceTreeNode[] {
|
||||
const sourceBefore = treeModel.find(prev, payload.id);
|
||||
if (!sourceBefore) return prev;
|
||||
const oldParentId = (sourceBefore as SpaceTreeNode).parentPageId ?? null;
|
||||
const newParentId = payload.parentId as string | null;
|
||||
|
||||
// Place the node by its fractional `position` among the new siblings — NOT by
|
||||
// the sender's absolute `index` (the sender computed that against its own
|
||||
// loaded set, which differs from this receiver's). Using the position keeps
|
||||
// the visible order correct on every client; placing at `index: 0` would
|
||||
// wrongly drop reordered/moved nodes at the top of their new sibling list.
|
||||
const placed = treeModel.placeByPosition(prev, payload.id, {
|
||||
parentId: newParentId,
|
||||
position: payload.position,
|
||||
});
|
||||
// `placeByPosition` silently returns the same reference if the destination
|
||||
// parent isn't loaded on this client. Falling back to removing the source
|
||||
// keeps the UI consistent (the source reappears when the user expands the new
|
||||
// parent and lazy-load fetches it).
|
||||
if (placed === prev) {
|
||||
return treeModel.remove(prev, payload.id);
|
||||
}
|
||||
|
||||
// Apply the authoritative node fields the move payload carries (`pageData`) so
|
||||
// receivers don't keep a stale title/icon/chevron on the moved node.
|
||||
// `placeByPosition` already set `position`.
|
||||
const pageData = payload.pageData as
|
||||
| {
|
||||
title?: string | null;
|
||||
icon?: string | null;
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
const patch: Partial<SpaceTreeNode> = {
|
||||
position: payload.position,
|
||||
// Honest type: a root move has a null parent, so this is `string | null`,
|
||||
// not always `string`.
|
||||
parentPageId: newParentId as string | null,
|
||||
};
|
||||
if (pageData) {
|
||||
// The tree node stores the title as `name`.
|
||||
if (pageData.title !== undefined) patch.name = pageData.title ?? "";
|
||||
if (pageData.icon !== undefined) patch.icon = pageData.icon ?? undefined;
|
||||
if (pageData.hasChildren !== undefined)
|
||||
patch.hasChildren = pageData.hasChildren;
|
||||
}
|
||||
let next = treeModel.update(placed, payload.id, patch);
|
||||
|
||||
// Mirror the emitter's hasChildren bookkeeping so both clients converge to the
|
||||
// same chevron state.
|
||||
if (oldParentId) {
|
||||
const oldParent = treeModel.find(next, oldParentId);
|
||||
if (!oldParent?.children?.length) {
|
||||
next = treeModel.update(next, oldParentId, {
|
||||
hasChildren: false,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
}
|
||||
if (newParentId) {
|
||||
next = treeModel.update(next, newParentId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
// `deleteTreeNode`: remove the node (and its descendants) from the tree.
|
||||
// Idempotent — if the node is already gone returns prev unchanged. Mirrors the
|
||||
// `hasChildren` bookkeeping: a parent left childless flips `hasChildren` false.
|
||||
//
|
||||
// PURE: the `queryClient.invalidateQueries` side effect lives in the hook, not
|
||||
// here.
|
||||
export function applyDeleteTreeNode(
|
||||
prev: SpaceTreeNode[],
|
||||
payload: DeleteTreeNodeEvent["payload"],
|
||||
): SpaceTreeNode[] {
|
||||
if (!treeModel.find(prev, payload.node.id)) return prev;
|
||||
let next = treeModel.remove(prev, payload.node.id);
|
||||
// Mirror the emitter's hasChildren bookkeeping so both clients converge to the
|
||||
// same chevron state when the last child is deleted.
|
||||
const parentPageId = payload.node.parentPageId;
|
||||
if (parentPageId) {
|
||||
const parent = treeModel.find(next, parentPageId);
|
||||
if (!parent?.children?.length) {
|
||||
next = treeModel.update(next, parentPageId, {
|
||||
hasChildren: false,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -6,6 +6,12 @@ import { WebSocketEvent } from "@/features/websocket/types";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import {
|
||||
applyUpdateOne,
|
||||
applyAddTreeNode,
|
||||
applyMoveTreeNode,
|
||||
applyDeleteTreeNode,
|
||||
} from "@/features/websocket/tree-socket-reducers.ts";
|
||||
import localEmitter from "@/lib/local-emitter.ts";
|
||||
|
||||
export const useTreeSocket = () => {
|
||||
@@ -35,138 +41,26 @@ export const useTreeSocket = () => {
|
||||
switch (event.operation) {
|
||||
case "updateOne":
|
||||
if (event.entity[0] === "pages") {
|
||||
setTreeData((prev) => {
|
||||
if (!treeModel.find(prev, event.id)) return prev;
|
||||
let next = prev;
|
||||
if (event.payload?.title !== undefined) {
|
||||
next = treeModel.update(next, event.id, {
|
||||
name: event.payload.title,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
if (event.payload?.icon !== undefined) {
|
||||
next = treeModel.update(next, event.id, {
|
||||
icon: event.payload.icon,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setTreeData((prev) => applyUpdateOne(prev, event));
|
||||
}
|
||||
break;
|
||||
case "addTreeNode":
|
||||
setTreeData((prev) => {
|
||||
// Idempotent: the author already inserted the node optimistically,
|
||||
// and a node may be re-delivered — never insert a duplicate id.
|
||||
if (treeModel.find(prev, event.payload.data.id)) return prev;
|
||||
const newParentId = event.payload.parentId as string | null;
|
||||
// Insert by `position` among already-loaded siblings (not the
|
||||
// sender's absolute index) so order is consistent across clients
|
||||
// with different loaded sets.
|
||||
let next = treeModel.insertByPosition(
|
||||
prev,
|
||||
newParentId,
|
||||
event.payload.data,
|
||||
);
|
||||
// Mirror the emitter: flip new parent's hasChildren to true so
|
||||
// the chevron renders on the receiver.
|
||||
if (newParentId) {
|
||||
next = treeModel.update(next, newParentId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setTreeData((prev) => applyAddTreeNode(prev, event.payload));
|
||||
break;
|
||||
case "moveTreeNode":
|
||||
setTreeData((prev) => {
|
||||
const sourceBefore = treeModel.find(prev, event.payload.id);
|
||||
if (!sourceBefore) return prev;
|
||||
const oldParentId =
|
||||
(sourceBefore as SpaceTreeNode).parentPageId ?? null;
|
||||
const newParentId = event.payload.parentId as string | null;
|
||||
|
||||
// Place the node by its fractional `position` among the new
|
||||
// siblings — NOT by the sender's absolute `index` (the sender
|
||||
// computed that against its own loaded set, which differs from
|
||||
// this receiver's). Using the position keeps the visible order
|
||||
// correct on every client; placing at `index: 0` would wrongly
|
||||
// drop reordered/moved nodes at the top of their new sibling list.
|
||||
const placed = treeModel.placeByPosition(prev, event.payload.id, {
|
||||
parentId: newParentId,
|
||||
position: event.payload.position,
|
||||
});
|
||||
// `placeByPosition` silently returns the same reference if the
|
||||
// destination parent isn't loaded on this client. Falling back to
|
||||
// removing the source keeps the UI consistent (the source will
|
||||
// reappear when the user expands the new parent and lazy-load
|
||||
// fetches it).
|
||||
if (placed === prev) {
|
||||
return treeModel.remove(prev, event.payload.id);
|
||||
}
|
||||
|
||||
// Apply the authoritative node fields the move payload carries
|
||||
// (`pageData`) so receivers don't keep a stale title/icon/chevron
|
||||
// on the moved node. `placeByPosition` already set `position`.
|
||||
const pageData = event.payload.pageData as
|
||||
| {
|
||||
title?: string | null;
|
||||
icon?: string | null;
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
const patch: Partial<SpaceTreeNode> = {
|
||||
position: event.payload.position,
|
||||
// Honest type: a root move has a null parent, so this is
|
||||
// `string | null`, not always `string`.
|
||||
parentPageId: newParentId as string | null,
|
||||
};
|
||||
if (pageData) {
|
||||
// The tree node stores the title as `name`.
|
||||
if (pageData.title !== undefined) patch.name = pageData.title ?? "";
|
||||
if (pageData.icon !== undefined)
|
||||
patch.icon = pageData.icon ?? undefined;
|
||||
if (pageData.hasChildren !== undefined)
|
||||
patch.hasChildren = pageData.hasChildren;
|
||||
}
|
||||
let next = treeModel.update(placed, event.payload.id, patch);
|
||||
|
||||
// Mirror the emitter's hasChildren bookkeeping so both clients
|
||||
// converge to the same chevron state.
|
||||
if (oldParentId) {
|
||||
const oldParent = treeModel.find(next, oldParentId);
|
||||
if (!oldParent?.children?.length) {
|
||||
next = treeModel.update(next, oldParentId, {
|
||||
hasChildren: false,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
}
|
||||
if (newParentId) {
|
||||
next = treeModel.update(next, newParentId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
setTreeData((prev) => applyMoveTreeNode(prev, event.payload));
|
||||
break;
|
||||
case "deleteTreeNode":
|
||||
// The `invalidateQueries` side effect stays in the hook; the tree
|
||||
// transform (`applyDeleteTreeNode`) is pure. Only invalidate when the
|
||||
// node is actually in the tree (mirrors the pure reducer's early-out).
|
||||
setTreeData((prev) => {
|
||||
if (!treeModel.find(prev, event.payload.node.id)) return prev;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["pages", event.payload.node.slugId].filter(Boolean),
|
||||
});
|
||||
let next = treeModel.remove(prev, event.payload.node.id);
|
||||
// Mirror the emitter's hasChildren bookkeeping so both clients
|
||||
// converge to the same chevron state when the last child is deleted.
|
||||
const parentPageId = event.payload.node.parentPageId;
|
||||
if (parentPageId) {
|
||||
const parent = treeModel.find(next, parentPageId);
|
||||
if (!parent?.children?.length) {
|
||||
next = treeModel.update(next, parentPageId, {
|
||||
hasChildren: false,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
if (treeModel.find(prev, event.payload.node.id)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["pages", event.payload.node.slugId].filter(Boolean),
|
||||
});
|
||||
}
|
||||
return next;
|
||||
return applyDeleteTreeNode(prev, event.payload);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveCardStatus } from './ai-provider-settings';
|
||||
import {
|
||||
resolveCardStatus,
|
||||
isEndpointConfigured,
|
||||
resolveKeyField,
|
||||
} from './ai-provider-settings';
|
||||
|
||||
describe('resolveCardStatus', () => {
|
||||
it('returns "off" when not configured and not enabled', () => {
|
||||
@@ -18,3 +22,52 @@ describe('resolveCardStatus', () => {
|
||||
expect(resolveCardStatus(true, true)).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEndpointConfigured', () => {
|
||||
it('configured when model and the endpoint own base URL are set', () => {
|
||||
expect(isEndpointConfigured('m', 'https://own', '')).toBe(true);
|
||||
});
|
||||
|
||||
it('configured by inheriting the chat base URL when own base is empty', () => {
|
||||
expect(isEndpointConfigured('m', '', 'https://chat')).toBe(true);
|
||||
});
|
||||
|
||||
it('not configured when model is set but both base URLs are empty', () => {
|
||||
expect(isEndpointConfigured('m', '', '')).toBe(false);
|
||||
});
|
||||
|
||||
it('not configured when both base URLs are whitespace-only', () => {
|
||||
expect(isEndpointConfigured('m', ' ', '\t')).toBe(false);
|
||||
});
|
||||
|
||||
it('not configured when the model is whitespace-only', () => {
|
||||
expect(isEndpointConfigured(' ', 'https://own', 'https://chat')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveKeyField (write-only key payload)', () => {
|
||||
// The same logic backs all three keys (chat / embedding / stt) in buildPayload.
|
||||
it('typed a value -> set the new key', () => {
|
||||
expect(resolveKeyField('sk-new', false)).toEqual({
|
||||
set: true,
|
||||
value: 'sk-new',
|
||||
});
|
||||
});
|
||||
|
||||
it('typed a value wins even if cleared was also flagged', () => {
|
||||
expect(resolveKeyField('sk-new', true)).toEqual({
|
||||
set: true,
|
||||
value: 'sk-new',
|
||||
});
|
||||
});
|
||||
|
||||
it('cleared (empty buffer) -> set the key to empty string', () => {
|
||||
expect(resolveKeyField('', true)).toEqual({ set: true, value: '' });
|
||||
});
|
||||
|
||||
it('untouched (empty buffer, not cleared) -> omit the key', () => {
|
||||
expect(resolveKeyField('', false)).toEqual({ set: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,33 @@ export function resolveCardStatus(
|
||||
return enabled ? "warning" : "off";
|
||||
}
|
||||
|
||||
// Pure + unit-testable. A non-chat endpoint (embeddings / voice) is "configured"
|
||||
// when its model is set AND it has a usable base URL: either its own base URL is
|
||||
// non-empty, or the chat base URL is non-empty (inherited when own is empty).
|
||||
// All inputs are trimmed so whitespace-only values do not count as filled.
|
||||
export function isEndpointConfigured(
|
||||
model: string,
|
||||
ownBase: string,
|
||||
chatBase: string,
|
||||
): boolean {
|
||||
return (
|
||||
model.trim() !== "" && (ownBase.trim() !== "" || chatBase.trim() !== "")
|
||||
);
|
||||
}
|
||||
|
||||
// Pure + unit-testable. Write-only API-key payload semantics:
|
||||
// - typed a value (buffer non-empty) -> set it
|
||||
// - explicitly cleared -> send '' to clear the stored key
|
||||
// - untouched (empty buffer, not cleared) -> omit the key entirely
|
||||
export function resolveKeyField(
|
||||
buffer: string,
|
||||
cleared: boolean,
|
||||
): { set: true; value: string } | { set: false } {
|
||||
if (buffer.length > 0) return { set: true, value: buffer };
|
||||
if (cleared) return { set: true, value: "" };
|
||||
return { set: false };
|
||||
}
|
||||
|
||||
// Translate the dot's tooltip label. Kept in one place so all three endpoint
|
||||
// cards share identical wording.
|
||||
function cardStatusLabel(status: CardStatus, t: (k: string) => string): string {
|
||||
@@ -263,29 +290,23 @@ export default function AiProviderSettings() {
|
||||
sttApiStyle: values.sttApiStyle,
|
||||
};
|
||||
|
||||
// Key semantics (never send the stored key back):
|
||||
// Key semantics (never send the stored key back) — see resolveKeyField:
|
||||
// - typed a value -> set it
|
||||
// - explicitly cleared -> send '' to clear
|
||||
// - untouched -> omit the key entirely (leave unchanged)
|
||||
if (values.apiKey.length > 0) {
|
||||
payload.apiKey = values.apiKey;
|
||||
} else if (keyCleared) {
|
||||
payload.apiKey = "";
|
||||
}
|
||||
const apiKeyField = resolveKeyField(values.apiKey, keyCleared);
|
||||
if (apiKeyField.set) payload.apiKey = apiKeyField.value;
|
||||
|
||||
// Same write-only semantics for the embedding-specific key.
|
||||
if (values.embeddingApiKey.length > 0) {
|
||||
payload.embeddingApiKey = values.embeddingApiKey;
|
||||
} else if (embeddingKeyCleared) {
|
||||
payload.embeddingApiKey = "";
|
||||
}
|
||||
const embeddingKeyField = resolveKeyField(
|
||||
values.embeddingApiKey,
|
||||
embeddingKeyCleared,
|
||||
);
|
||||
if (embeddingKeyField.set) payload.embeddingApiKey = embeddingKeyField.value;
|
||||
|
||||
// Same write-only semantics for the STT-specific key.
|
||||
if (values.sttApiKey.length > 0) {
|
||||
payload.sttApiKey = values.sttApiKey;
|
||||
} else if (sttKeyCleared) {
|
||||
payload.sttApiKey = "";
|
||||
}
|
||||
const sttKeyField = resolveKeyField(values.sttApiKey, sttKeyCleared);
|
||||
if (sttKeyField.set) payload.sttApiKey = sttKeyField.value;
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -460,12 +481,12 @@ export default function AiProviderSettings() {
|
||||
const v = form.values;
|
||||
const chatBase = v.baseUrl.trim();
|
||||
const chatConfigured = v.chatModel.trim() !== "" && chatBase !== "";
|
||||
const embedConfigured =
|
||||
v.embeddingModel.trim() !== "" &&
|
||||
(v.embeddingBaseUrl.trim() !== "" || chatBase !== "");
|
||||
const sttConfigured =
|
||||
v.sttModel.trim() !== "" &&
|
||||
(v.sttBaseUrl.trim() !== "" || chatBase !== "");
|
||||
const embedConfigured = isEndpointConfigured(
|
||||
v.embeddingModel,
|
||||
v.embeddingBaseUrl,
|
||||
v.baseUrl,
|
||||
);
|
||||
const sttConfigured = isEndpointConfigured(v.sttModel, v.sttBaseUrl, v.baseUrl);
|
||||
|
||||
const chatStatus = resolveCardStatus(chatConfigured, chatEnabled);
|
||||
const embedStatus = resolveCardStatus(embedConfigured, searchEnabled);
|
||||
|
||||
Reference in New Issue
Block a user