feat: support cross-space page mentions (#1979)

This commit is contained in:
Philip Okugbe 2026-03-01 17:14:10 +00:00 committed by GitHub
parent dcc2bacb22
commit 2309d1434b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 103 additions and 71 deletions

View file

@ -130,6 +130,7 @@
"pages": "pages", "pages": "pages",
"Password": "Password", "Password": "Password",
"Password changed successfully": "Password changed successfully", "Password changed successfully": "Password changed successfully",
"People": "People",
"Pending": "Pending", "Pending": "Pending",
"Please confirm your action": "Please confirm your action", "Please confirm your action": "Please confirm your action",
"Preferences": "Preferences", "Preferences": "Preferences",

View file

@ -33,7 +33,6 @@ export const handlePaste = (
const url = clipboardData.trim(); const url = clipboardData.trim();
const { from: pos, empty } = editor.state.selection; const { from: pos, empty } = editor.state.selection;
const match = INTERNAL_LINK_REGEX.exec(url); const match = INTERNAL_LINK_REGEX.exec(url);
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
// pasted link must be from the same workspace/domain and must not be on a selection // pasted link must be from the same workspace/domain and must not be on a selection
if (!empty || match[2] !== window.location.host) { if (!empty || match[2] !== window.location.host) {
@ -41,12 +40,6 @@ export const handlePaste = (
return false; return false;
} }
// for now, we only support internal links from the same space
// compare space name
if (currentPageMatch[4].toLowerCase() !== match[4].toLowerCase()) {
return false;
}
const anchorId = match[6] ? match[6].split("#")[0] : undefined; const anchorId = match[6] ? match[6].split("#")[0] : undefined;
const urlWithoutAnchor = anchorId const urlWithoutAnchor = anchorId
? url.substring(0, url.indexOf("#")) ? url.substring(0, url.indexOf("#"))

View file

@ -31,13 +31,17 @@ import {
MentionSuggestionItem, MentionSuggestionItem,
} from "@/features/editor/components/mention/mention.type.ts"; } from "@/features/editor/components/mention/mention.type.ts";
import { IPage } from "@/features/page/types/page.types"; import { IPage } from "@/features/page/types/page.types";
import { useCreatePageMutation, usePageQuery } from "@/features/page/queries/page-query"; import {
useCreatePageMutation,
usePageQuery,
} from "@/features/page/queries/page-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { SimpleTree } from "react-arborist"; import { SimpleTree } from "react-arborist";
import { SpaceTreeNode } from "@/features/page/tree/types"; import { SpaceTreeNode } from "@/features/page/tree/types";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit"; import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
const MentionList = forwardRef<any, MentionListProps>((props, ref) => { const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(1); const [selectedIndex, setSelectedIndex] = useState(1);
@ -59,11 +63,11 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
includeUsers: true, includeUsers: true,
includePages: true, includePages: true,
spaceId: space.id, spaceId: space.id,
limit: 10, limit: props.query ? 10 : 5,
preload: true, preload: true,
}); });
const createPageItem = (label: string) : MentionSuggestionItem => { const createPageItem = (label: string): MentionSuggestionItem => {
return { return {
id: null, id: null,
label: label, label: label,
@ -71,15 +75,15 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
entityId: null, entityId: null,
slugId: null, slugId: null,
icon: null, icon: null,
} };
} };
useEffect(() => { useEffect(() => {
if (suggestion && !isLoading) { if (suggestion && !isLoading) {
let items: MentionSuggestionItem[] = []; let items: MentionSuggestionItem[] = [];
if (suggestion?.users?.length > 0) { if (suggestion?.users?.length > 0) {
items.push({ entityType: "header", label: t("Users") }); items.push({ entityType: "header", label: t("People") });
items = items.concat( items = items.concat(
suggestion.users.map((user) => ({ suggestion.users.map((user) => ({
@ -97,11 +101,13 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
items = items.concat( items = items.concat(
suggestion.pages.map((page) => ({ suggestion.pages.map((page) => ({
id: uuid7(), id: uuid7(),
label: page.title || "Untitled", label: page.title || t("Untitled"),
entityType: "page", entityType: "page",
entityId: page.id, entityId: page.id,
slugId: page.slugId, slugId: page.slugId,
icon: page.icon, icon: page.icon,
spaceName: page.space?.name,
spaceSlug: page.space?.slug,
})), })),
); );
} }
@ -129,17 +135,17 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
creatorId: currentUser?.user.id, creatorId: currentUser?.user.id,
}); });
} }
if (item.entityType === "page" && item.id!==null) { if (item.entityType === "page" && item.id !== null) {
props.command({ props.command({
id: item.id, id: item.id,
label: item.label || "Untitled", label: item.label || t("Untitled"),
entityType: "page", entityType: "page",
entityId: item.entityId, entityId: item.entityId,
slugId: item.slugId, slugId: item.slugId,
creatorId: currentUser?.user.id, creatorId: currentUser?.user.id,
}); });
} }
if (item.entityType === "page" && item.id===null) { if (item.entityType === "page" && item.id === null) {
createPage(item.label); createPage(item.label);
} }
} }
@ -207,7 +213,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const payload: { spaceId: string; parentPageId?: string; title: string } = { const payload: { spaceId: string; parentPageId?: string; title: string } = {
spaceId: space.id, spaceId: space.id,
parentPageId: page.id || null, parentPageId: page.id || null,
title: title title: title,
}; };
let createdPage: IPage; let createdPage: IPage;
@ -249,11 +255,10 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
}, },
}); });
}, 50); }, 50);
} catch (err) { } catch (err) {
throw new Error("Failed to create page"); throw new Error("Failed to create page");
} }
} };
useEffect(() => { useEffect(() => {
viewportRef.current viewportRef.current
@ -267,15 +272,19 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
return ( return (
<Paper id="mention" shadow="md" py="xs" withBorder radius="md"> <Paper id="mention" shadow="md" py="xs" withBorder radius="md">
<Text c="dimmed" size="sm" px="sm"> <Text c="dimmed" size="sm" px="sm">
{ t("No results") } {t("No results")}
</Text> </Text>
</Paper> </Paper>
); );
} }
const hasUsers = renderItems.some((item) => item.entityType === "user"); const hasUsers = renderItems.some((item) => item.entityType === "user");
const hasPages = renderItems.some((item) => item.entityType === "page" && item.id !== null); const hasPages = renderItems.some(
const createPageItemData = renderItems.find((item) => item.entityType === "page" && item.id === null); (item) => item.entityType === "page" && item.id !== null,
);
const createPageItemData = renderItems.find(
(item) => item.entityType === "page" && item.id === null,
);
return ( return (
<Paper id="mention" shadow="md" withBorder radius="md" py={6}> <Paper id="mention" shadow="md" withBorder radius="md" py={6}>
@ -283,7 +292,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
viewportRef={viewportRef} viewportRef={viewportRef}
mah={350} mah={350}
w={popupWidth} w={popupWidth}
scrollbars={"y"}
scrollbarSize={6} scrollbarSize={6}
styles={{ content: { minWidth: 0 } }}
> >
{renderItems?.map((item, index) => { {renderItems?.map((item, index) => {
if (item.entityType === "header") { if (item.entityType === "header") {
@ -299,6 +310,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
pt={isFirst ? 2 : 4} pt={isFirst ? 2 : 4}
pb={4} pb={4}
tt="uppercase" tt="uppercase"
style={{ userSelect: "none" }}
> >
{item.label} {item.label}
</Text> </Text>
@ -323,9 +335,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
/> />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Text size="sm" fw={500}> <AutoTooltipText size="sm" fw={500}>
{item.label} {item.label}
</Text> </AutoTooltipText>
</div> </div>
</Group> </Group>
</UnstyledButton> </UnstyledButton>
@ -355,9 +367,14 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
</ActionIcon> </ActionIcon>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate> <AutoTooltipText size="sm" fw={500} truncate>
{item.label} {item.label}
</AutoTooltipText>
{item.spaceName && (
<Text size="xs" c="dimmed" truncate>
{item.spaceName}
</Text> </Text>
)}
</div> </div>
</Group> </Group>
</UnstyledButton> </UnstyledButton>
@ -372,9 +389,12 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
{(hasUsers || hasPages) && <Divider my={6} />} {(hasUsers || hasPages) && <Divider my={6} />}
<UnstyledButton <UnstyledButton
data-item-index={renderItems.indexOf(createPageItemData)} data-item-index={renderItems.indexOf(createPageItemData)}
onClick={() => selectItem(renderItems.indexOf(createPageItemData))} onClick={() =>
selectItem(renderItems.indexOf(createPageItemData))
}
className={clsx(classes.menuBtn, { className={clsx(classes.menuBtn, {
[classes.selectedItem]: renderItems.indexOf(createPageItemData) === selectedIndex, [classes.selectedItem]:
renderItems.indexOf(createPageItemData) === selectedIndex,
})} })}
px="sm" px="sm"
> >
@ -388,7 +408,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
<IconPlus size={16} stroke={1.5} /> <IconPlus size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0, overflow: "hidden" }}>
<Text size="sm" fw={500} truncate> <Text size="sm" fw={500} truncate>
{t("Create page")}: {createPageItemData.label} {t("Create page")}: {createPageItemData.label}
</Text> </Text>

View file

@ -106,7 +106,7 @@ const mentionRenderItems = () => {
left: `${x}px`, left: `${x}px`,
top: `${y}px`, top: `${y}px`,
position: "absolute", position: "absolute",
zIndex: "9999", zIndex: "100",
}); });
}); });
}, },

View file

@ -54,12 +54,20 @@ export default function MentionView(props: NodeViewProps) {
</Text> </Text>
)} )}
{entityType === "page" && ( {entityType === "page" && isError && (
<Text component="span" c="dimmed" size="sm">
{label}
</Text>
)}
{entityType === "page" && !isError && (
<Anchor <Anchor
component={Link} component={Link}
fw={500} fw={500}
to={ to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId) isShareRoute
? shareSlugUrl
: buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId)
} }
onClick={handleClick} onClick={handleClick}
underline="never" underline="never"

View file

@ -26,4 +26,6 @@ export type MentionSuggestionItem =
entityId: string; entityId: string;
slugId: string; slugId: string;
icon: string; icon: string;
spaceName?: string;
spaceSlug?: string;
}; };

View file

@ -198,6 +198,7 @@ export class SearchService {
let pageSearch = this.db let pageSearch = this.db
.selectFrom('pages') .selectFrom('pages')
.select(['id', 'slugId', 'title', 'icon', 'spaceId']) .select(['id', 'slugId', 'title', 'icon', 'spaceId'])
.select((eb) => this.pageRepo.withSpace(eb))
.where((eb) => .where((eb) =>
eb( eb(
sql`LOWER(f_unaccent(pages.title))`, sql`LOWER(f_unaccent(pages.title))`,
@ -209,17 +210,19 @@ export class SearchService {
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.limit(limit); .limit(limit);
// only search spaces the user has access to // search all spaces the user has access to, prioritizing the current space
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
if (suggestion?.spaceId) { if (userSpaceIds?.length > 0) {
if (userSpaceIds.includes(suggestion.spaceId)) {
pageSearch = pageSearch.where('spaceId', '=', suggestion.spaceId);
pages = await pageSearch.execute();
}
} else if (userSpaceIds?.length > 0) {
// we need this check or the query will throw an error if the userSpaceIds array is empty
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds); pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
if (suggestion?.spaceId) {
pageSearch = pageSearch.orderBy(
sql`CASE WHEN pages."space_id" = ${suggestion.spaceId} THEN 0 ELSE 1 END`,
'asc',
);
}
pages = await pageSearch.execute(); pages = await pageSearch.execute();
} }
@ -230,7 +233,6 @@ export class SearchService {
await this.pagePermissionRepo.filterAccessiblePageIds({ await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds, pageIds,
userId, userId,
spaceId: suggestion?.spaceId,
}); });
const accessibleSet = new Set(accessibleIds); const accessibleSet = new Set(accessibleIds);
pages = pages.filter((p) => accessibleSet.has(p.id)); pages = pages.filter((p) => accessibleSet.has(p.id));

View file

@ -1,10 +1,4 @@
import { import { Global, Logger, Module, OnApplicationBootstrap } from '@nestjs/common';
Global,
Logger,
Module,
OnApplicationBootstrap,
BeforeApplicationShutdown,
} from '@nestjs/common';
import { InjectKysely, KyselyModule } from 'nestjs-kysely'; import { InjectKysely, KyselyModule } from 'nestjs-kysely';
import { EnvironmentService } from '../integrations/environment/environment.service'; import { EnvironmentService } from '../integrations/environment/environment.service';
import { CamelCasePlugin, LogEvent, sql } from 'kysely'; import { CamelCasePlugin, LogEvent, sql } from 'kysely';
@ -107,9 +101,7 @@ import { normalizePostgresUrl } from '../common/helpers';
WatcherRepo, WatcherRepo,
], ],
}) })
export class DatabaseModule export class DatabaseModule implements OnApplicationBootstrap {
implements OnApplicationBootstrap, BeforeApplicationShutdown
{
private readonly logger = new Logger(DatabaseModule.name); private readonly logger = new Logger(DatabaseModule.name);
constructor( constructor(
@ -126,12 +118,6 @@ export class DatabaseModule
} }
} }
async beforeApplicationShutdown(): Promise<void> {
if (this.db) {
await this.db.destroy();
}
}
async establishConnection() { async establishConnection() {
const retryAttempts = 15; const retryAttempts = 15;
const retryDelay = 3000; const retryDelay = 3000;

View file

@ -33,6 +33,7 @@ import slugify = require('@sindresorhus/slugify');
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
const packageJson = require('../../../package.json'); const packageJson = require('../../../package.json');
import { EnvironmentService } from '../environment/environment.service'; import { EnvironmentService } from '../environment/environment.service';
import { DomainService } from '../environment/domain.service';
import { import {
getAttachmentIds, getAttachmentIds,
getProsemirrorContent, getProsemirrorContent,
@ -49,6 +50,7 @@ export class ExportService {
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService, private readonly storageService: StorageService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private readonly domainService: DomainService,
) {} ) {}
async exportPage(format: string, page: Page, singlePage?: boolean) { async exportPage(format: string, page: Page, singlePage?: boolean) {
@ -61,9 +63,11 @@ export class ExportService {
let prosemirrorJson: any; let prosemirrorJson: any;
if (singlePage) { if (singlePage) {
const baseUrl = await this.getWorkspaceBaseUrl(page.workspaceId);
prosemirrorJson = await this.turnPageMentionsToLinks( prosemirrorJson = await this.turnPageMentionsToLinks(
getProsemirrorContent(page.content), getProsemirrorContent(page.content),
page.workspaceId, page.workspaceId,
baseUrl,
); );
} else { } else {
// mentions is already turned to links during the zip process // mentions is already turned to links during the zip process
@ -149,12 +153,14 @@ export class ExportService {
const tree = buildTree(pages as Page[]); const tree = buildTree(pages as Page[]);
const baseUrl = await this.getWorkspaceBaseUrl(pages[0].workspaceId);
const zip = new JSZip(); const zip = new JSZip();
await this.zipPages( await this.zipPages(
tree, tree,
format, format,
zip, zip,
includeAttachments, includeAttachments,
baseUrl,
userId, userId,
ignorePermissions, ignorePermissions,
); );
@ -218,6 +224,7 @@ export class ExportService {
const tree = buildTree(pages as Page[]); const tree = buildTree(pages as Page[]);
const baseUrl = await this.getWorkspaceBaseUrl(pages[0].workspaceId);
const zip = new JSZip(); const zip = new JSZip();
await this.zipPages( await this.zipPages(
@ -225,6 +232,7 @@ export class ExportService {
format, format,
zip, zip,
includeAttachments, includeAttachments,
baseUrl,
userId, userId,
ignorePermissions, ignorePermissions,
); );
@ -248,6 +256,7 @@ export class ExportService {
format: string, format: string,
zip: JSZip, zip: JSZip,
includeAttachments: boolean, includeAttachments: boolean,
baseUrl: string,
userId?: string, userId?: string,
ignorePermissions = false, ignorePermissions = false,
): Promise<void> { ): Promise<void> {
@ -271,6 +280,7 @@ export class ExportService {
const prosemirrorJson = await this.turnPageMentionsToLinks( const prosemirrorJson = await this.turnPageMentionsToLinks(
getProsemirrorContent(page.content), getProsemirrorContent(page.content),
page.workspaceId, page.workspaceId,
baseUrl,
userId, userId,
ignorePermissions, ignorePermissions,
); );
@ -360,6 +370,7 @@ export class ExportService {
async turnPageMentionsToLinks( async turnPageMentionsToLinks(
prosemirrorJson: any, prosemirrorJson: any,
workspaceId: string, workspaceId: string,
baseUrl: string,
userId?: string, userId?: string,
ignorePermissions = false, ignorePermissions = false,
) { ) {
@ -429,8 +440,7 @@ export class ExportService {
const truncatedTitle = linkTitle?.substring(0, 70); const truncatedTitle = linkTitle?.substring(0, 70);
const pageSlug = `${slugify(truncatedTitle)}-${slugId}`; const pageSlug = `${slugify(truncatedTitle)}-${slugId}`;
// Create the link URL const link = `${baseUrl}/s/${spaceSlug}/p/${pageSlug}`;
const link = `${this.environmentService.getAppUrl()}/s/${spaceSlug}/p/${pageSlug}`;
// Create a link mark and a text node with that mark // Create a link mark and a text node with that mark
const linkMark = editorState.schema.marks.link.create({ href: link }); const linkMark = editorState.schema.marks.link.create({ href: link });
@ -476,6 +486,16 @@ export class ExportService {
return updatedDoc.toJSON(); return updatedDoc.toJSON();
} }
private async getWorkspaceBaseUrl(workspaceId: string): Promise<string> {
const workspace = await this.db
.selectFrom('workspaces')
.select('hostname')
.where('id', '=', workspaceId)
.executeTakeFirst();
return this.domainService.getUrl(workspace?.hostname);
}
private async filterPagesForExport( private async filterPagesForExport(
pages: Page[], pages: Page[],
rootPageId: string | null, rootPageId: string | null,