feat: subpages component improvements (#2488)
* expand subpages node functionality * minor fixes * minor fix * refactor: redesign subpages view * fix link color --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
This commit is contained in:
parent
42e07d8019
commit
2e3fdbbff4
17 changed files with 786 additions and 154 deletions
|
|
@ -626,6 +626,10 @@
|
|||
"Restore this base to view its contents.": "Restore this base to view its contents.",
|
||||
"Subpages": "Subpages",
|
||||
"Failed to load subpages": "Failed to load subpages",
|
||||
"Sort subpages": "Sort subpages",
|
||||
"Manual": "Manual",
|
||||
"A–Z": "A–Z",
|
||||
"Recent": "Recent",
|
||||
"No subpages": "No subpages",
|
||||
"Subpages (Child pages)": "Subpages (Child pages)",
|
||||
"List all subpages of the current page": "List all subpages of the current page",
|
||||
|
|
@ -978,6 +982,8 @@
|
|||
"Page menu": "Page menu",
|
||||
"Expand": "Expand",
|
||||
"Collapse": "Collapse",
|
||||
"Expand {{name}}": "Expand {{name}}",
|
||||
"Collapse {{name}}": "Collapse {{name}}",
|
||||
"Comment menu": "Comment menu",
|
||||
"Group menu": "Group menu",
|
||||
"Show hidden breadcrumbs": "Show hidden breadcrumbs",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
import { ActionIcon, Collapse } from "@mantine/core";
|
||||
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import type { SubpagesSortBy } from "@docmost/editor-ext";
|
||||
import {
|
||||
buildPageUrl,
|
||||
buildPublicSpaceUrl,
|
||||
buildSharedPageUrl,
|
||||
} from "@/features/page/page.utils";
|
||||
import { formattedDate, shortTimeAgo } from "@/lib/time";
|
||||
import { useSubpages } from "./use-subpages";
|
||||
import type { SubpageListItem } from "./subpages.utils";
|
||||
import classes from "./subpages.module.css";
|
||||
|
||||
type SubpageItemProps = {
|
||||
page: SubpageListItem;
|
||||
depth: number;
|
||||
sortBy: SubpagesSortBy;
|
||||
showUpdatedAt: boolean;
|
||||
isPublicSpaceRoute: boolean;
|
||||
shareId?: string;
|
||||
spaceSlug?: string;
|
||||
};
|
||||
|
||||
export default function SubpageItem({
|
||||
page,
|
||||
depth,
|
||||
sortBy,
|
||||
showUpdatedAt,
|
||||
isPublicSpaceRoute,
|
||||
shareId,
|
||||
spaceSlug,
|
||||
}: SubpageItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const { subpages: children, isLoading } = useSubpages(
|
||||
page.id,
|
||||
sortBy,
|
||||
opened,
|
||||
);
|
||||
|
||||
const title = page.title || t("untitled");
|
||||
const updatedAt =
|
||||
showUpdatedAt && page.updatedAt ? new Date(page.updatedAt) : null;
|
||||
|
||||
const pageUrl = shareId
|
||||
? buildSharedPageUrl({
|
||||
shareId,
|
||||
pageSlugId: page.slugId,
|
||||
pageTitle: page.title,
|
||||
})
|
||||
: isPublicSpaceRoute
|
||||
? buildPublicSpaceUrl({
|
||||
spaceSlug,
|
||||
pageSlugId: page.slugId,
|
||||
pageTitle: page.title,
|
||||
})
|
||||
: buildPageUrl(spaceSlug, page.slugId, page.title);
|
||||
|
||||
return (
|
||||
<div role="listitem">
|
||||
<div
|
||||
className={classes.row}
|
||||
style={{ paddingInlineStart: 8 + depth * 24 }}
|
||||
>
|
||||
{page.hasChildren ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={22}
|
||||
className={classes.toggle}
|
||||
onClick={() => setOpened((value) => !value)}
|
||||
aria-label={
|
||||
opened
|
||||
? t("Collapse {{name}}", { name: title })
|
||||
: t("Expand {{name}}", { name: title })
|
||||
}
|
||||
aria-expanded={opened}
|
||||
>
|
||||
<IconChevronRight
|
||||
size={16}
|
||||
className={clsx(classes.chevron, opened && classes.chevronOpened)}
|
||||
/>
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<span className={classes.togglePlaceholder} />
|
||||
)}
|
||||
|
||||
<Link to={pageUrl} className={classes.link} draggable={false}>
|
||||
<span className={classes.icon} aria-hidden>
|
||||
{page.icon || <IconFileDescription size={18} stroke={1.5} />}
|
||||
</span>
|
||||
<span className={classes.title}>{title}</span>
|
||||
{updatedAt && (
|
||||
<time
|
||||
className={classes.updatedAt}
|
||||
dateTime={updatedAt.toISOString()}
|
||||
title={formattedDate(updatedAt)}
|
||||
>
|
||||
{shortTimeAgo(updatedAt)}
|
||||
</time>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{page.hasChildren && (
|
||||
<Collapse expanded={opened} keepMounted={false}>
|
||||
{isLoading ? (
|
||||
<div
|
||||
className={classes.loading}
|
||||
style={{ paddingInlineStart: 8 + (depth + 1) * 24 }}
|
||||
>
|
||||
{t("Loading…")}
|
||||
</div>
|
||||
) : (
|
||||
<div role="list">
|
||||
{children.map((child) => (
|
||||
<SubpageItem
|
||||
key={child.id}
|
||||
page={child}
|
||||
depth={depth + 1}
|
||||
sortBy={sortBy}
|
||||
showUpdatedAt={showUpdatedAt}
|
||||
isPublicSpaceRoute={isPublicSpaceRoute}
|
||||
shareId={shareId}
|
||||
spaceSlug={spaceSlug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Collapse>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ export const SubpagesMenu = React.memo(
|
|||
updateDelay={0}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<Tooltip position="top" label={t("Delete")}>
|
||||
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={deleteNode}
|
||||
variant="default"
|
||||
|
|
|
|||
|
|
@ -1,30 +1,29 @@
|
|||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
|
||||
import { useMemo } from "react";
|
||||
import { Link, useLocation, useParams } from "react-router-dom";
|
||||
import classes from "./subpages.module.css";
|
||||
import styles from "../mention/mention.module.css";
|
||||
import {
|
||||
buildPageUrl,
|
||||
buildPublicSpaceUrl,
|
||||
buildSharedPageUrl,
|
||||
} from "@/features/page/page.utils.ts";
|
||||
import { NodeViewProps, NodeViewWrapper, useEditorState } from "@tiptap/react";
|
||||
import { SegmentedControl } from "@mantine/core";
|
||||
import { useId } from "react";
|
||||
import { useLocation, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
|
||||
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { SubpagesSortBy } from "@docmost/editor-ext";
|
||||
import { publicSpaceTreeDataAtom } from "@/features/public-space/atoms/public-space-atoms.ts";
|
||||
import { findSubpagesInTree } from "@/features/share/utils";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import SubpageItem from "./subpage-item";
|
||||
import { useSubpages } from "./use-subpages";
|
||||
import classes from "./subpages.module.css";
|
||||
|
||||
export default function SubpagesView(props: NodeViewProps) {
|
||||
const { editor } = props;
|
||||
const { editor, node, updateAttributes } = props;
|
||||
const { spaceSlug, shareId, pageSlug } = useParams();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const headingId = useId();
|
||||
const isShareRoute = location.pathname.startsWith("/share/");
|
||||
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
|
||||
const isPublicView = isShareRoute || isPublicSpaceRoute;
|
||||
const isEditable = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => ctx.editor.isEditable,
|
||||
});
|
||||
|
||||
const publicSpaceTreeData = useAtomValue(publicSpaceTreeDataAtom);
|
||||
|
||||
|
|
@ -33,7 +32,7 @@ export default function SubpagesView(props: NodeViewProps) {
|
|||
const routePageId = extractPageSlugId(pageSlug);
|
||||
let currentPageId = storagePageId;
|
||||
|
||||
if (shareId){
|
||||
if (isShareRoute){
|
||||
currentPageId = routePageId;
|
||||
}
|
||||
|
||||
|
|
@ -45,127 +44,60 @@ export default function SubpagesView(props: NodeViewProps) {
|
|||
currentPageId = routePageId ?? publicSpaceTreeData?.[0]?.slugId;
|
||||
}
|
||||
|
||||
// Get subpages from shared tree if we're in a shared context
|
||||
const sharedSubpages = useSharedPageSubpages(currentPageId);
|
||||
const publicSpaceSubpages = useMemo(
|
||||
() => findSubpagesInTree(publicSpaceTreeData, currentPageId),
|
||||
[publicSpaceTreeData, currentPageId],
|
||||
);
|
||||
const sortBy: SubpagesSortBy = node.attrs.sortBy || "default";
|
||||
const { subpages, isLoading, error } = useSubpages(currentPageId, sortBy);
|
||||
|
||||
const isPublicView = Boolean(shareId) || isPublicSpaceRoute;
|
||||
|
||||
const { data, isLoading, error } = useGetSidebarPagesQuery(
|
||||
isPublicView ? null : { pageId: currentPageId },
|
||||
);
|
||||
|
||||
const subpages = useMemo(() => {
|
||||
// If we're in a shared context, use the shared subpages
|
||||
if (shareId && sharedSubpages) {
|
||||
return sharedSubpages.map((node) => ({
|
||||
id: node.value,
|
||||
slugId: node.slugId,
|
||||
title: node.name,
|
||||
icon: node.icon,
|
||||
position: node.position,
|
||||
}));
|
||||
}
|
||||
|
||||
if (isPublicSpaceRoute) {
|
||||
return publicSpaceSubpages.map((node) => ({
|
||||
id: node.value,
|
||||
slugId: node.slugId,
|
||||
title: node.name,
|
||||
icon: node.icon,
|
||||
position: node.position,
|
||||
}));
|
||||
}
|
||||
|
||||
// Otherwise use the API data
|
||||
if (!data?.pages) return [];
|
||||
const allPages = data.pages.flatMap((page) => page.items);
|
||||
return sortPositionKeys(allPages);
|
||||
}, [
|
||||
data,
|
||||
shareId,
|
||||
sharedSubpages,
|
||||
isPublicSpaceRoute,
|
||||
publicSpaceSubpages,
|
||||
]);
|
||||
|
||||
if (isLoading && !isPublicView) {
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error && !isPublicView) {
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<Text c="dimmed" size="md" py="md">
|
||||
{t("Failed to load subpages")}
|
||||
</Text>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (subpages.length === 0) {
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<div className={classes.container}>
|
||||
<Text c="dimmed" size="md" py="md">
|
||||
{t("No subpages")}
|
||||
</Text>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<div className={classes.container}>
|
||||
<Stack gap={5}>
|
||||
{subpages.map((page) => (
|
||||
<Anchor
|
||||
key={page.id}
|
||||
component={Link}
|
||||
fw={500}
|
||||
to={
|
||||
shareId
|
||||
? buildSharedPageUrl({
|
||||
shareId,
|
||||
pageSlugId: page.slugId,
|
||||
pageTitle: page.title,
|
||||
})
|
||||
: isPublicSpaceRoute
|
||||
? buildPublicSpaceUrl({
|
||||
spaceSlug,
|
||||
pageSlugId: page.slugId,
|
||||
pageTitle: page.title,
|
||||
})
|
||||
: buildPageUrl(spaceSlug, page.slugId, page.title)
|
||||
}
|
||||
underline="never"
|
||||
className={styles.pageMentionLink}
|
||||
draggable={false}
|
||||
>
|
||||
{page?.icon ? (
|
||||
<span style={{ marginRight: "4px" }}>{page.icon}</span>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
component="span"
|
||||
size={18}
|
||||
style={{ verticalAlign: "text-bottom" }}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<div className={classes.card}>
|
||||
<div className={classes.header}>
|
||||
<div id={headingId} className={classes.heading}>
|
||||
{t("Subpages")}
|
||||
{subpages.length > 0 && (
|
||||
<span className={classes.count}>{subpages.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={styles.pageMentionText}>
|
||||
{page?.title || t("untitled")}
|
||||
</span>
|
||||
</Anchor>
|
||||
))}
|
||||
</Stack>
|
||||
{isEditable && subpages.length > 1 && (
|
||||
<SegmentedControl<SubpagesSortBy>
|
||||
size="xs"
|
||||
className={classes.sortControl}
|
||||
value={sortBy}
|
||||
onChange={(value) => updateAttributes({ sortBy: value })}
|
||||
data={[
|
||||
{ label: t("Manual"), value: "default" },
|
||||
{ label: t("A–Z"), value: "title-asc" },
|
||||
{ label: t("Recent"), value: "recent" },
|
||||
]}
|
||||
aria-label={t("Sort subpages")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subpages.length > 0 ? (
|
||||
<div role="list" aria-labelledby={headingId} className={classes.list}>
|
||||
{subpages.map((page) => (
|
||||
<SubpageItem
|
||||
key={page.id}
|
||||
page={page}
|
||||
depth={0}
|
||||
sortBy={sortBy}
|
||||
showUpdatedAt={!isPublicView}
|
||||
isPublicSpaceRoute={isPublicSpaceRoute}
|
||||
shareId={shareId}
|
||||
spaceSlug={spaceSlug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={classes.empty}>
|
||||
{error ? t("Failed to load subpages") : t("No subpages")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,173 @@
|
|||
.container {
|
||||
margin: 0;
|
||||
padding-left: 4px;
|
||||
.card {
|
||||
/* lighter than dimmed but still AA on the header */
|
||||
--subpages-dimmed: light-dark(
|
||||
#6b7280,
|
||||
color-mix(in srgb, var(--mantine-color-dark-1), var(--mantine-color-dark-2))
|
||||
);
|
||||
--subpages-icon: light-dark(
|
||||
var(--mantine-color-gray-6),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
border: 1px solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
border-radius: var(--mantine-radius-md);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
|
||||
a {
|
||||
border: none !important;
|
||||
&:hover .sortControl,
|
||||
&:focus-within .sortControl {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* round the selection outline to match the card */
|
||||
:global(.ProseMirror .node-subpages) {
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
:global(.ProseMirror-selectednode) .sortControl {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
min-height: rem(44px);
|
||||
padding-block: rem(6px);
|
||||
padding-inline: rem(16px) rem(8px);
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
border-bottom: 1px solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: rem(8px);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-weight: 500;
|
||||
color: var(--subpages-dimmed);
|
||||
}
|
||||
|
||||
.sortControl {
|
||||
margin-inline-start: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
|
||||
@media (hover: none) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: rem(12px) rem(16px);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: var(--subpages-dimmed);
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: rem(4px);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: rem(4px);
|
||||
min-height: rem(36px);
|
||||
padding-inline-end: rem(12px);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
|
||||
&:hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.togglePlaceholder {
|
||||
flex: none;
|
||||
width: rem(22px);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--subpages-icon);
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.toggle:hover .chevron {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chevronOpened {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
gap: rem(8px);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-inline-start: rem(4px);
|
||||
}
|
||||
|
||||
/* beat the editor's link styles */
|
||||
.card .row .link {
|
||||
font-weight: inherit;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: rem(20px);
|
||||
font-size: rem(17px);
|
||||
line-height: 1;
|
||||
color: var(--subpages-icon);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: rem(15px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.updatedAt {
|
||||
flex: none;
|
||||
padding-inline-start: rem(12px);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
color: var(--subpages-dimmed);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: rem(36px);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: var(--subpages-dimmed);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { sortSubpages, type SubpageListItem } from "./subpages.utils";
|
||||
|
||||
// input order differs from every expected order
|
||||
const pages: SubpageListItem[] = [
|
||||
{
|
||||
id: "a",
|
||||
slugId: "a",
|
||||
title: "Alpha",
|
||||
position: "a3",
|
||||
hasChildren: false,
|
||||
updatedAt: "2026-10-02T09:00:00.000Z",
|
||||
},
|
||||
{ id: "c", slugId: "c", title: "charlie", position: "a2", hasChildren: false },
|
||||
{
|
||||
id: "d",
|
||||
slugId: "d",
|
||||
title: "Delta",
|
||||
position: "a1",
|
||||
hasChildren: true,
|
||||
updatedAt: "2026-10-01T09:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
slugId: "b",
|
||||
title: "beta",
|
||||
position: "a0",
|
||||
hasChildren: false,
|
||||
updatedAt: "2026-10-03T09:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const ids = (items: SubpageListItem[]) => items.map((item) => item.id);
|
||||
|
||||
describe("sortSubpages", () => {
|
||||
it("keeps the manual position order by default", () => {
|
||||
expect(ids(sortSubpages(pages, "default"))).toEqual(["b", "d", "c", "a"]);
|
||||
});
|
||||
|
||||
it("sorts titles A to Z ignoring case", () => {
|
||||
expect(ids(sortSubpages(pages, "title-asc"))).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("sorts numbers inside titles by value", () => {
|
||||
const chapters = ["Chapter 10", "Chapter 2", "Chapter 1"].map(
|
||||
(title, index) => ({
|
||||
id: title,
|
||||
slugId: title,
|
||||
title,
|
||||
position: `a${index}`,
|
||||
hasChildren: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(ids(sortSubpages(chapters, "title-asc"))).toEqual([
|
||||
"Chapter 1",
|
||||
"Chapter 2",
|
||||
"Chapter 10",
|
||||
]);
|
||||
});
|
||||
|
||||
it("puts the most recently updated first and undated pages last", () => {
|
||||
expect(ids(sortSubpages(pages, "recent"))).toEqual(["b", "a", "d", "c"]);
|
||||
});
|
||||
|
||||
it("falls back to the manual order for an unknown sort value", () => {
|
||||
expect(ids(sortSubpages(pages, "title-desc" as never))).toEqual([
|
||||
"b",
|
||||
"d",
|
||||
"c",
|
||||
"a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not reorder the input array", () => {
|
||||
sortSubpages(pages, "recent");
|
||||
sortSubpages(pages, "default");
|
||||
expect(ids(pages)).toEqual(["a", "c", "d", "b"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import type { SubpagesSortBy } from "@docmost/editor-ext";
|
||||
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
|
||||
|
||||
export type SubpageListItem = {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
position: string;
|
||||
hasChildren: boolean;
|
||||
updatedAt?: Date | string;
|
||||
};
|
||||
|
||||
export function sortSubpages(
|
||||
items: SubpageListItem[],
|
||||
sortBy: SubpagesSortBy,
|
||||
): SubpageListItem[] {
|
||||
const sortedItems = [...items];
|
||||
|
||||
if (sortBy === "title-asc") {
|
||||
return sortedItems.sort((a, b) =>
|
||||
(a.title || "").localeCompare(b.title || "", undefined, {
|
||||
sensitivity: "base",
|
||||
numeric: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (sortBy === "recent") {
|
||||
return sortedItems.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updatedAt ?? 0).getTime() -
|
||||
new Date(a.updatedAt ?? 0).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
return sortPositionKeys(sortedItems);
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createStore, Provider as JotaiProvider } from "jotai";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import type { SubpagesSortBy } from "@docmost/editor-ext";
|
||||
import { getSidebarPages } from "@/features/page/services/page-service";
|
||||
import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
|
||||
import { publicSpaceTreeDataAtom } from "@/features/public-space/atoms/public-space-atoms.ts";
|
||||
import { buildSharedPageTree } from "@/features/share/utils";
|
||||
import { useSubpages } from "./use-subpages";
|
||||
|
||||
vi.mock("@/main.tsx", async () => ({
|
||||
queryClient: new (await import("@tanstack/react-query")).QueryClient(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/services/page-service", () => ({
|
||||
getSidebarPages: vi.fn(),
|
||||
}));
|
||||
|
||||
const sidebarPage = (
|
||||
id: string,
|
||||
title: string,
|
||||
position: string,
|
||||
updatedAt: string,
|
||||
hasChildren: boolean,
|
||||
) => ({
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
title,
|
||||
icon: null,
|
||||
position,
|
||||
parentPageId: "p1",
|
||||
spaceId: "space-1",
|
||||
creatorId: "user-1",
|
||||
isBase: false,
|
||||
deletedAt: null,
|
||||
hasChildren,
|
||||
canEdit: true,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
const treePage = (
|
||||
id: string,
|
||||
title: string,
|
||||
position: string,
|
||||
parentPageId: string | null,
|
||||
updatedAt: string,
|
||||
) => ({
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
title,
|
||||
icon: null,
|
||||
position,
|
||||
parentPageId,
|
||||
spaceId: "space-1",
|
||||
workspaceId: "workspace-1",
|
||||
isRestricted: false,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
const treePages = [
|
||||
treePage("p1", "Parent", "a0", null, "2026-10-01T00:00:00.000Z"),
|
||||
treePage("c1", "Older child", "a0", "p1", "2026-10-02T00:00:00.000Z"),
|
||||
treePage("c2", "Newer child", "a1", "p1", "2026-10-05T00:00:00.000Z"),
|
||||
treePage("g1", "Grandchild", "a0", "c1", "2026-10-03T00:00:00.000Z"),
|
||||
];
|
||||
|
||||
function renderSubpages(
|
||||
path: string,
|
||||
pageId: string,
|
||||
sortBy: SubpagesSortBy,
|
||||
{ enabled = true, store = createStore() } = {},
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JotaiProvider store={store}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/s/:spaceSlug/p/:pageSlug" element={children} />
|
||||
<Route path="/share/:shareId/p/:pageSlug" element={children} />
|
||||
<Route path="/share/p/:pageSlug" element={children} />
|
||||
<Route path="/docs/:spaceSlug/:pageSlug" element={children} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</JotaiProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
return renderHook(() => useSubpages(pageId, sortBy, enabled), { wrapper });
|
||||
}
|
||||
|
||||
const ids = (items: { id: string }[]) => items.map((item) => item.id);
|
||||
|
||||
describe("useSubpages", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSidebarPages).mockReset();
|
||||
});
|
||||
|
||||
it("loads children of the page from the sidebar API in the app", async () => {
|
||||
vi.mocked(getSidebarPages).mockResolvedValue({
|
||||
items: [
|
||||
sidebarPage("c1", "Older child", "a0", "2026-10-02T00:00:00.000Z", true),
|
||||
sidebarPage("c2", "Newer child", "a1", "2026-10-05T00:00:00.000Z", false),
|
||||
],
|
||||
meta: {
|
||||
limit: 100,
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false,
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const { result } = renderSubpages("/s/general/p/parent-p1", "p1", "recent");
|
||||
|
||||
await waitFor(() => expect(ids(result.current.subpages)).toEqual(["c2", "c1"]));
|
||||
expect(getSidebarPages).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pageId: "p1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not request children until enabled", () => {
|
||||
const { result } = renderSubpages("/s/general/p/parent-p1", "p1", "default", {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
expect(result.current.subpages).toEqual([]);
|
||||
expect(getSidebarPages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads children from the share tree without calling the API", () => {
|
||||
const store = createStore();
|
||||
store.set(sharedTreeDataAtom, buildSharedPageTree(treePages as never));
|
||||
|
||||
const { result } = renderSubpages("/share/abc/p/parent-slug-p1", "slug-p1", "recent", {
|
||||
store,
|
||||
});
|
||||
|
||||
expect(ids(result.current.subpages)).toEqual(["c2", "c1"]);
|
||||
expect(getSidebarPages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a share link without a share key as a share", () => {
|
||||
const store = createStore();
|
||||
store.set(sharedTreeDataAtom, buildSharedPageTree(treePages as never));
|
||||
|
||||
const { result } = renderSubpages("/share/p/parent-slug-p1", "slug-p1", "default", {
|
||||
store,
|
||||
});
|
||||
|
||||
expect(ids(result.current.subpages)).toEqual(["c1", "c2"]);
|
||||
expect(getSidebarPages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads children from the public docs tree without calling the API", () => {
|
||||
const store = createStore();
|
||||
store.set(publicSpaceTreeDataAtom, buildSharedPageTree(treePages as never));
|
||||
|
||||
const { result } = renderSubpages("/docs/handbook/parent-slug-p1", "p1", "default", {
|
||||
store,
|
||||
});
|
||||
|
||||
expect(
|
||||
result.current.subpages.map(({ id, slugId, title, hasChildren }) => ({
|
||||
id,
|
||||
slugId,
|
||||
title,
|
||||
hasChildren,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "c1", slugId: "slug-c1", title: "Older child", hasChildren: true },
|
||||
{ id: "c2", slugId: "slug-c2", title: "Newer child", hasChildren: false },
|
||||
]);
|
||||
expect(getSidebarPages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { SubpagesSortBy } from "@docmost/editor-ext";
|
||||
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
|
||||
import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
|
||||
import { publicSpaceTreeDataAtom } from "@/features/public-space/atoms/public-space-atoms.ts";
|
||||
import { findSubpagesInTree } from "@/features/share/utils";
|
||||
import { sortSubpages } from "./subpages.utils";
|
||||
|
||||
export function useSubpages(
|
||||
pageId: string | undefined,
|
||||
sortBy: SubpagesSortBy,
|
||||
enabled = true,
|
||||
) {
|
||||
const { pathname } = useLocation();
|
||||
const isShareRoute = pathname.startsWith("/share/");
|
||||
const isPublicView = isShareRoute || pathname.startsWith("/docs/");
|
||||
|
||||
const sharedTreeData = useAtomValue(sharedTreeDataAtom);
|
||||
const publicSpaceTreeData = useAtomValue(publicSpaceTreeDataAtom);
|
||||
|
||||
const { data, isLoading, error } = useGetSidebarPagesQuery(
|
||||
!isPublicView && enabled ? { pageId } : null,
|
||||
);
|
||||
|
||||
const subpages = useMemo(() => {
|
||||
if (isPublicView) {
|
||||
const tree = isShareRoute ? sharedTreeData : publicSpaceTreeData;
|
||||
const nodes = findSubpagesInTree(tree, pageId).map((node) => ({
|
||||
id: node.value,
|
||||
slugId: node.slugId,
|
||||
title: node.name,
|
||||
icon: node.icon,
|
||||
position: node.position,
|
||||
hasChildren: node.hasChildren,
|
||||
updatedAt: node.updatedAt,
|
||||
}));
|
||||
return sortSubpages(nodes, sortBy);
|
||||
}
|
||||
|
||||
const pages = data?.pages.flatMap((page) => page.items) ?? [];
|
||||
return sortSubpages(pages, sortBy);
|
||||
}, [
|
||||
isPublicView,
|
||||
isShareRoute,
|
||||
sharedTreeData,
|
||||
publicSpaceTreeData,
|
||||
pageId,
|
||||
data,
|
||||
sortBy,
|
||||
]);
|
||||
|
||||
return { subpages, isLoading, error };
|
||||
}
|
||||
|
|
@ -813,7 +813,7 @@
|
|||
color: var(--docs-accent);
|
||||
}
|
||||
|
||||
/* Internal page links (mentions, subpages lists) follow the accent like any
|
||||
/* Internal page links (mentions) follow the accent like any
|
||||
* other link; the app skin pins them to ink with !important, hence the
|
||||
* counter-!important, and the underline tint softens to match. */
|
||||
.root :global(.ProseMirror) a[class*="pageMentionLink"] {
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
import { useMemo } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
|
||||
import { findSubpagesInTree } from "@/features/share/utils";
|
||||
|
||||
export function useSharedPageSubpages(pageId: string | undefined) {
|
||||
const treeData = useAtomValue(sharedTreeDataAtom);
|
||||
|
||||
return useMemo(
|
||||
() => findSubpagesInTree(treeData, pageId),
|
||||
[treeData, pageId],
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ export type SharedPageTreeNode = {
|
|||
position: string;
|
||||
spaceId: string;
|
||||
parentPageId: string;
|
||||
updatedAt?: Date;
|
||||
hasChildren: boolean;
|
||||
children: SharedPageTreeNode[];
|
||||
label: string;
|
||||
|
|
@ -32,6 +33,7 @@ export function buildSharedPageTree(
|
|||
hasChildren: false,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId,
|
||||
updatedAt: page.updatedAt,
|
||||
label: page.title || "untitled",
|
||||
value: page.id,
|
||||
children: [],
|
||||
|
|
|
|||
|
|
@ -9,6 +9,31 @@ export function timeAgo(date: Date) {
|
|||
});
|
||||
}
|
||||
|
||||
const RELATIVE_TIME_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||
["year", 365 * 86400],
|
||||
["month", 30 * 86400],
|
||||
["week", 7 * 86400],
|
||||
["day", 86400],
|
||||
["hour", 3600],
|
||||
["minute", 60],
|
||||
];
|
||||
|
||||
export function shortTimeAgo(date: Date) {
|
||||
const seconds = (Date.now() - new Date(date).getTime()) / 1000;
|
||||
const unit = RELATIVE_TIME_UNITS.find(([, unitSeconds]) => seconds >= unitSeconds);
|
||||
|
||||
if (!unit) {
|
||||
return new Intl.RelativeTimeFormat(i18n.language, {
|
||||
numeric: "auto",
|
||||
}).format(0, "second");
|
||||
}
|
||||
|
||||
const [name, unitSeconds] = unit;
|
||||
return new Intl.RelativeTimeFormat(i18n.language, {
|
||||
style: "narrow",
|
||||
}).format(-Math.floor(seconds / unitSeconds), name);
|
||||
}
|
||||
|
||||
export function formattedDate(date: Date) {
|
||||
const locale = getDateFnsLocale();
|
||||
if (isToday(date)) {
|
||||
|
|
|
|||
|
|
@ -312,6 +312,7 @@ export class PageService {
|
|||
'spaceId',
|
||||
'creatorId',
|
||||
'isBase',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
])
|
||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||
|
|
|
|||
|
|
@ -607,6 +607,7 @@ export class PageRepo {
|
|||
'pages.parentPageId',
|
||||
'pages.spaceId',
|
||||
'pages.workspaceId',
|
||||
'pages.updatedAt',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('pages.content'))
|
||||
|
|
@ -626,6 +627,7 @@ export class PageRepo {
|
|||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.workspaceId',
|
||||
'p.updatedAt',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
|
||||
|
|
@ -644,6 +646,7 @@ export class PageRepo {
|
|||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'updatedAt',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
// Filter out restricted pages from the result
|
||||
|
|
@ -671,6 +674,7 @@ export class PageRepo {
|
|||
'pages.parentPageId',
|
||||
'pages.spaceId',
|
||||
'pages.workspaceId',
|
||||
'pages.updatedAt',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
])
|
||||
.where('pages.spaceId', '=', spaceId)
|
||||
|
|
@ -690,6 +694,7 @@ export class PageRepo {
|
|||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.workspaceId',
|
||||
'p.updatedAt',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
])
|
||||
.where('p.deletedAt', 'is', null)
|
||||
|
|
@ -706,6 +711,7 @@ export class PageRepo {
|
|||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'updatedAt',
|
||||
])
|
||||
.where('isRestricted', '=', false)
|
||||
.execute();
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export { Subpages } from "./subpages";
|
||||
export type { SubpagesAttributes, SubpagesOptions } from "./subpages";
|
||||
export type { SubpagesAttributes, SubpagesOptions, SubpagesSortBy } from "./subpages";
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ export interface SubpagesOptions {
|
|||
view: any;
|
||||
}
|
||||
|
||||
export interface SubpagesAttributes {}
|
||||
export type SubpagesSortBy = "default" | "title-asc" | "recent";
|
||||
|
||||
export interface SubpagesAttributes {
|
||||
sortBy?: SubpagesSortBy;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
|
|
@ -26,6 +30,19 @@ export const Subpages = Node.create<SubpagesOptions>({
|
|||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
sortBy: {
|
||||
default: "default",
|
||||
parseHTML: (element: HTMLElement) =>
|
||||
element.getAttribute("data-sort-by") || "default",
|
||||
renderHTML: (attributes: SubpagesAttributes) => ({
|
||||
"data-sort-by": attributes.sortBy || "default",
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
|
|
|||
Loading…
Reference in a new issue