Batch of fixes from the automated QA pass on develop. Each was reproduced and then verified fixed live (browser/curl); logic-bearing fixes have unit tests. Functional bugs: - #122 collab-token was capped by the anonymous public-share-AI throttler (5/min); skip all non-AUTH named throttlers on this auth-guarded, client-cached route. - #123 editor onAuthenticationFailed threw `jwtDecode(undefined)` and never reconnected; read the token via a ref, guard the decode (incl. missing exp), and refetch+reconnect on any auth failure. - #124 a slash command containing a space ("/Heading 1") inserted literal text; enable allowSpaces and close the menu when the query matches no items. - #125 space slug auto-gen produced uppercase initials for multi-word names; computeSpaceSlug now yields a lowercase alphanumeric slug. - #126 AI chat window position/size now persisted (atomWithStorage) across reload; also fixes a latent ResizeObserver-attach bug on first open. - #127 workspace name update accepted URLs; add @NoUrls (parity with setup). - #132 icon-columns 4/5 passed calc() into SVG width/height attrs (console spam); size via style. share-for-page query returns null instead of undefined. - #134 "Reindex now" counter looked stuck: reindex runs async; the client now polls coverage (bounded) so the counter climbs live; misleading server comment reworded. UX / consistency: - #128 add success toasts to favorite/label/avatar/member-(de)activate. - #129 "1 result found" pluralization; hide the single-option Type filter. - #130 replace raw Zod strings with friendly messages (name/password/group). - #131 unify "Untitled" casing in tree/breadcrumb/tab; stop force-uppercasing space-name chips; fix confirm-dialog labels (Cancel / Remove), invite placeholder typo, Export/Move-to-space labels. - #133 disable profile Save when clean; toast on unsupported avatar image; style the invalid-invitation page with a CTA; hide Share for read-only users; align the dictation "not configured" message; "Go to login page" typo. Tests: computeSpaceSlug, workspace-name NoUrls DTO, share-query null normalization, slash getSuggestionItems empty-close. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
312 lines
8.5 KiB
TypeScript
312 lines
8.5 KiB
TypeScript
import { useRef } from "react";
|
|
import { Link, useParams } from "react-router-dom";
|
|
import { useAtom } from "jotai";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
|
import {
|
|
IconChevronDown,
|
|
IconChevronRight,
|
|
IconFileDescription,
|
|
IconPlus,
|
|
IconPointFilled,
|
|
IconTemplate,
|
|
} from "@tabler/icons-react";
|
|
|
|
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
|
import { queryClient } from "@/main.tsx";
|
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
|
import { getPageById } from "@/features/page/services/page-service.ts";
|
|
import {
|
|
useUpdatePageMutation,
|
|
fetchAllAncestorChildren,
|
|
} from "@/features/page/queries/page-query.ts";
|
|
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
|
|
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
|
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
|
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
|
import type { RenderRowProps } from "./doc-tree";
|
|
import { NodeMenu } from "./space-tree-node-menu";
|
|
import classes from "@/features/page/tree/styles/tree.module.css";
|
|
import { updateTreeNodeIcon } from "@/features/page/tree/utils/utils.ts";
|
|
|
|
type SpaceTreeRowProps = RenderRowProps<SpaceTreeNode> & {
|
|
readOnly: boolean;
|
|
};
|
|
|
|
export function SpaceTreeRow({
|
|
node,
|
|
isOpen,
|
|
hasChildren,
|
|
toggleOpen,
|
|
rowRef,
|
|
tabIndex,
|
|
treeItemProps,
|
|
readOnly,
|
|
}: SpaceTreeRowProps) {
|
|
const { t } = useTranslation();
|
|
const { spaceSlug } = useParams();
|
|
const updatePageMutation = useUpdatePageMutation();
|
|
const [, setTreeData] = useAtom(treeDataAtom);
|
|
const emit = useQueryEmit();
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
|
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
|
|
|
const canEdit = !readOnly && node.canEdit !== false;
|
|
const pageUrl = buildPageUrl(spaceSlug, node.slugId, node.name);
|
|
|
|
const prefetchPage = () => {
|
|
timerRef.current = setTimeout(async () => {
|
|
const page = await queryClient.fetchQuery({
|
|
queryKey: ["pages", node.id],
|
|
queryFn: () => getPageById({ pageId: node.id }),
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
if (page?.slugId) {
|
|
queryClient.setQueryData(["pages", page.slugId], page);
|
|
}
|
|
}, 150);
|
|
};
|
|
|
|
const cancelPagePrefetch = () => {
|
|
if (timerRef.current) {
|
|
window.clearTimeout(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
};
|
|
|
|
const handleUpdateNodeIcon = (nodeId: string, newIcon: string | null) => {
|
|
setTreeData((prev) =>
|
|
updateTreeNodeIcon(prev, nodeId, newIcon),
|
|
);
|
|
};
|
|
|
|
const handleEmojiIconClick = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
const handleEmojiSelect = (emoji: { native: string }) => {
|
|
handleUpdateNodeIcon(node.id, emoji.native);
|
|
updatePageMutation
|
|
.mutateAsync({ pageId: node.id, icon: emoji.native })
|
|
.then((data) => {
|
|
setTimeout(() => {
|
|
emit({
|
|
operation: "updateOne",
|
|
spaceId: node.spaceId,
|
|
entity: ["pages"],
|
|
id: node.id,
|
|
payload: { icon: emoji.native, parentPageId: data.parentPageId },
|
|
});
|
|
}, 50);
|
|
});
|
|
};
|
|
|
|
const handleRemoveEmoji = () => {
|
|
handleUpdateNodeIcon(node.id, null);
|
|
updatePageMutation.mutateAsync({ pageId: node.id, icon: null });
|
|
|
|
setTimeout(() => {
|
|
emit({
|
|
operation: "updateOne",
|
|
spaceId: node.spaceId,
|
|
entity: ["pages"],
|
|
id: node.id,
|
|
payload: { icon: null },
|
|
});
|
|
}, 50);
|
|
};
|
|
|
|
const handleLoadChildren = async () => {
|
|
if (!node.hasChildren) return;
|
|
try {
|
|
const childrenTree = await fetchAllAncestorChildren({
|
|
pageId: node.id,
|
|
spaceId: node.spaceId,
|
|
});
|
|
setTreeData((prev) =>
|
|
treeModel.appendChildren(prev, node.id, childrenTree),
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to fetch children:", error);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Link
|
|
ref={rowRef as React.Ref<HTMLAnchorElement>}
|
|
to={pageUrl}
|
|
className={classes.node}
|
|
tabIndex={tabIndex}
|
|
{...treeItemProps}
|
|
onClick={() => {
|
|
if (mobileSidebarOpened) {
|
|
toggleMobileSidebar();
|
|
}
|
|
}}
|
|
onMouseEnter={prefetchPage}
|
|
onMouseLeave={cancelPagePrefetch}
|
|
>
|
|
<PageArrow
|
|
isOpen={isOpen}
|
|
hasChildren={hasChildren}
|
|
onToggle={toggleOpen}
|
|
/>
|
|
|
|
<div onClick={handleEmojiIconClick} style={{ marginRight: "4px" }}>
|
|
<EmojiPicker
|
|
onEmojiSelect={handleEmojiSelect}
|
|
icon={
|
|
node.icon ? node.icon : <IconFileDescription size="18" />
|
|
}
|
|
readOnly={!canEdit}
|
|
removeEmojiAction={handleRemoveEmoji}
|
|
actionIconProps={{ tabIndex: -1 }}
|
|
/>
|
|
</div>
|
|
|
|
<span className={classes.text}>{node.name || t("Untitled")}</span>
|
|
|
|
{node.isTemplate === true && (
|
|
<Tooltip label={t("Template")} withArrow>
|
|
<IconTemplate
|
|
size={14}
|
|
stroke={1.5}
|
|
// Visual-only indicator: subtle and never shrinks. Pointer events
|
|
// stay enabled so the Tooltip's hover handlers fire; clicks fall
|
|
// through to the row link since no stopPropagation is used.
|
|
style={{
|
|
flexShrink: 0,
|
|
marginLeft: rem(4),
|
|
color: "var(--mantine-color-dimmed)",
|
|
}}
|
|
aria-label={t("Template")}
|
|
role="img"
|
|
/>
|
|
</Tooltip>
|
|
)}
|
|
|
|
<div className={classes.actions}>
|
|
<NodeMenu node={node} canEdit={canEdit} />
|
|
|
|
{canEdit && (
|
|
<CreateNode
|
|
node={node}
|
|
isOpen={isOpen}
|
|
hasChildren={hasChildren}
|
|
onToggle={toggleOpen}
|
|
onExpandTree={handleLoadChildren}
|
|
/>
|
|
)}
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
interface PageArrowProps {
|
|
isOpen: boolean;
|
|
hasChildren: boolean;
|
|
onToggle: () => void;
|
|
}
|
|
|
|
function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) {
|
|
const { t } = useTranslation();
|
|
|
|
if (!hasChildren) {
|
|
return (
|
|
<span
|
|
aria-hidden
|
|
className={classes.actionIcon}
|
|
style={{
|
|
width: 20,
|
|
height: 20,
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<IconPointFilled size={8} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ActionIcon
|
|
size={20}
|
|
variant="subtle"
|
|
color="gray"
|
|
className={classes.actionIcon}
|
|
aria-label={isOpen ? t("Collapse") : t("Expand")}
|
|
aria-expanded={isOpen}
|
|
tabIndex={-1}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onToggle();
|
|
}}
|
|
>
|
|
{isOpen ? (
|
|
<IconChevronDown stroke={2} size={18} />
|
|
) : (
|
|
<IconChevronRight stroke={2} size={18} />
|
|
)}
|
|
</ActionIcon>
|
|
);
|
|
}
|
|
|
|
interface CreateNodeProps {
|
|
node: SpaceTreeNode;
|
|
isOpen: boolean;
|
|
hasChildren: boolean;
|
|
onToggle: () => void;
|
|
onExpandTree: () => Promise<void> | void;
|
|
}
|
|
|
|
function CreateNode({
|
|
node,
|
|
isOpen,
|
|
hasChildren,
|
|
onToggle,
|
|
onExpandTree,
|
|
}: CreateNodeProps) {
|
|
const { t } = useTranslation();
|
|
const { handleCreate } = useTreeMutation(node.spaceId);
|
|
|
|
async function handleClickCreate() {
|
|
if (node.hasChildren && !hasChildren) {
|
|
// Expand and lazy-load before creating a child. handleCreate reads the
|
|
// latest tree imperatively (via useStore) so we no longer need a
|
|
// setTimeout to wait for React to rerun the closure with fresh data.
|
|
if (!isOpen) onToggle();
|
|
await onExpandTree();
|
|
} else if (!isOpen) {
|
|
onToggle();
|
|
}
|
|
handleCreate(node.id);
|
|
}
|
|
|
|
return (
|
|
<ActionIcon
|
|
size={20}
|
|
variant="subtle"
|
|
color="gray"
|
|
className={classes.actionIcon}
|
|
aria-label={t("Create subpage of {{name}}", { name: node.name || t("Untitled") })}
|
|
tabIndex={-1}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleClickCreate();
|
|
}}
|
|
>
|
|
<IconPlus style={{ width: rem(20), height: rem(20) }} stroke={2} />
|
|
</ActionIcon>
|
|
);
|
|
}
|