Merge remote-tracking branch 'gitea/develop' into feat/share-chat-reuse-internal
This commit is contained in:
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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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