mirror of
https://github.com/docmost/docmost.git
synced 2026-05-18 23:44:24 +08:00
page property
This commit is contained in:
@@ -0,0 +1,268 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Popover, ActionIcon, Text } from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { IconX, IconFileDescription } from "@tabler/icons-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { IBaseProperty } from "@/features/base/types/base.types";
|
||||||
|
import { useResolvedPages } from "@/features/base/queries/base-page-resolver-query";
|
||||||
|
import { useBaseQuery } from "@/features/base/queries/base-query";
|
||||||
|
import { searchSuggestions } from "@/features/search/services/search-service";
|
||||||
|
import { buildPageUrl } from "@/features/page/page.utils";
|
||||||
|
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
|
||||||
|
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||||
|
|
||||||
|
type CellPageProps = {
|
||||||
|
value: unknown;
|
||||||
|
property: IBaseProperty;
|
||||||
|
rowId: string;
|
||||||
|
isEditing: boolean;
|
||||||
|
onCommit: (value: unknown) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PageSuggestion = {
|
||||||
|
id: string;
|
||||||
|
slugId: string;
|
||||||
|
title: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
spaceId: string;
|
||||||
|
space?: { id: string; slug: string; name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parsePageId(value: unknown): string | null {
|
||||||
|
if (typeof value === "string" && value.length > 0) return value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CellPage({
|
||||||
|
value,
|
||||||
|
property,
|
||||||
|
isEditing,
|
||||||
|
onCommit,
|
||||||
|
onCancel,
|
||||||
|
}: CellPageProps) {
|
||||||
|
const pageId = parsePageId(value);
|
||||||
|
const { data: base } = useBaseQuery(property.baseId);
|
||||||
|
|
||||||
|
const ids = useMemo(() => (pageId ? [pageId] : []), [pageId]);
|
||||||
|
const { pages } = useResolvedPages(ids);
|
||||||
|
const resolvedPage = pageId ? pages.get(pageId) : undefined;
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<PagePicker
|
||||||
|
pageId={pageId}
|
||||||
|
resolvedPage={resolvedPage ?? null}
|
||||||
|
spaceId={base?.spaceId}
|
||||||
|
onCommit={onCommit}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pageId) {
|
||||||
|
return <span className={cellClasses.emptyValue} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedPage === undefined) {
|
||||||
|
// Still resolving — render an empty pill-shaped placeholder to avoid
|
||||||
|
// the "Page not found" flicker on initial load.
|
||||||
|
return <span className={cellClasses.emptyValue} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedPage === null) {
|
||||||
|
return (
|
||||||
|
<span className={cellClasses.pageMissing}>
|
||||||
|
<IconFileDescription size={14} />
|
||||||
|
<span>Page not found</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PagePill page={resolvedPage} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PillPage = {
|
||||||
|
slugId: string;
|
||||||
|
title: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
space: { slug: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function PagePill({ page }: { page: PillPage }) {
|
||||||
|
const title = page.title || "Untitled";
|
||||||
|
const spaceSlug = page.space?.slug ?? "";
|
||||||
|
const url = buildPageUrl(spaceSlug, page.slugId, title);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={url}
|
||||||
|
className={cellClasses.pagePill}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{page.icon ? (
|
||||||
|
<span className={cellClasses.pagePillIcon}>{page.icon}</span>
|
||||||
|
) : (
|
||||||
|
<IconFileDescription size={14} className={cellClasses.pagePillIconFallback} />
|
||||||
|
)}
|
||||||
|
<span className={cellClasses.pagePillText}>{title}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PagePickerProps = {
|
||||||
|
pageId: string | null;
|
||||||
|
resolvedPage: { id: string; slugId: string; title: string | null; icon: string | null; space: { id: string; slug: string; name: string } | null } | null;
|
||||||
|
spaceId?: string;
|
||||||
|
onCommit: (value: unknown) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function PagePicker({
|
||||||
|
pageId,
|
||||||
|
resolvedPage,
|
||||||
|
spaceId,
|
||||||
|
onCommit,
|
||||||
|
onCancel,
|
||||||
|
}: PagePickerProps) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 250);
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => searchRef.current?.focus());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const trimmed = debouncedSearch.trim();
|
||||||
|
const { data: suggestions = [] } = useQuery({
|
||||||
|
queryKey: ["bases", "pages", "search", trimmed, spaceId ?? ""],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await searchSuggestions({
|
||||||
|
query: trimmed,
|
||||||
|
includePages: true,
|
||||||
|
spaceId,
|
||||||
|
limit: trimmed ? 25 : 5,
|
||||||
|
});
|
||||||
|
return (res.pages ?? []) as PageSuggestion[];
|
||||||
|
},
|
||||||
|
staleTime: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
|
||||||
|
useListKeyboardNav(suggestions.length, [debouncedSearch]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
onCommit(id === pageId ? null : id);
|
||||||
|
},
|
||||||
|
[pageId, onCommit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemove = useCallback(() => {
|
||||||
|
onCommit(null);
|
||||||
|
}, [onCommit]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (handleNavKey(e)) return;
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (activeIndex < 0 || activeIndex >= suggestions.length) return;
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelect(suggestions[activeIndex].id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onCancel, handleNavKey, activeIndex, suggestions, handleSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover opened onClose={onCancel} position="bottom-start" width={320} trapFocus>
|
||||||
|
<Popover.Target>
|
||||||
|
<div style={{ width: "100%", height: "100%" }}>
|
||||||
|
{resolvedPage ? <PagePill page={resolvedPage} /> : <span className={cellClasses.emptyValue} />}
|
||||||
|
</div>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown p={0}>
|
||||||
|
<div className={cellClasses.personTagArea}>
|
||||||
|
{pageId && resolvedPage && (
|
||||||
|
<span className={cellClasses.personTag}>
|
||||||
|
{resolvedPage.icon ? (
|
||||||
|
<span>{resolvedPage.icon}</span>
|
||||||
|
) : (
|
||||||
|
<IconFileDescription size={14} />
|
||||||
|
)}
|
||||||
|
<span className={cellClasses.personTagName}>
|
||||||
|
{resolvedPage.title || "Untitled"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cellClasses.personTagRemove}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemove();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX size={10} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
className={cellClasses.personTagInput}
|
||||||
|
placeholder={pageId ? "" : "Search for a page..."}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cellClasses.personDropdownDivider} />
|
||||||
|
<div className={cellClasses.selectDropdown}>
|
||||||
|
{suggestions.length === 0 && (
|
||||||
|
<div className={cellClasses.personDropdownHint}>
|
||||||
|
{trimmed ? "No pages found" : "No pages yet"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{suggestions.map((page, idx) => {
|
||||||
|
const isSelected = page.id === pageId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={page.id}
|
||||||
|
ref={setOptionRef(idx)}
|
||||||
|
className={clsx(
|
||||||
|
cellClasses.selectOption,
|
||||||
|
isSelected && cellClasses.selectOptionActive,
|
||||||
|
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => setActiveIndex(idx)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => handleSelect(page.id)}
|
||||||
|
>
|
||||||
|
{page.icon ? (
|
||||||
|
<span>{page.icon}</span>
|
||||||
|
) : (
|
||||||
|
<IconFileDescription size={14} />
|
||||||
|
)}
|
||||||
|
<span className={cellClasses.personOptionName}>
|
||||||
|
{page.title || "Untitled"}
|
||||||
|
</span>
|
||||||
|
{page.space?.name && (
|
||||||
|
<Text size="xs" c="dimmed" ml="auto" truncate>
|
||||||
|
{page.space.name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { CellUrl } from "@/features/base/components/cells/cell-url";
|
|||||||
import { CellEmail } from "@/features/base/components/cells/cell-email";
|
import { CellEmail } from "@/features/base/components/cells/cell-email";
|
||||||
import { CellPerson } from "@/features/base/components/cells/cell-person";
|
import { CellPerson } from "@/features/base/components/cells/cell-person";
|
||||||
import { CellFile } from "@/features/base/components/cells/cell-file";
|
import { CellFile } from "@/features/base/components/cells/cell-file";
|
||||||
|
import { CellPage } from "@/features/base/components/cells/cell-page";
|
||||||
import { CellCreatedAt } from "@/features/base/components/cells/cell-created-at";
|
import { CellCreatedAt } from "@/features/base/components/cells/cell-created-at";
|
||||||
import { CellLastEditedAt } from "@/features/base/components/cells/cell-last-edited-at";
|
import { CellLastEditedAt } from "@/features/base/components/cells/cell-last-edited-at";
|
||||||
import { CellLastEditedBy } from "@/features/base/components/cells/cell-last-edited-by";
|
import { CellLastEditedBy } from "@/features/base/components/cells/cell-last-edited-by";
|
||||||
@@ -45,6 +46,7 @@ const cellComponents: Record<
|
|||||||
email: CellEmail,
|
email: CellEmail,
|
||||||
person: CellPerson,
|
person: CellPerson,
|
||||||
file: CellFile,
|
file: CellFile,
|
||||||
|
page: CellPage,
|
||||||
createdAt: CellCreatedAt,
|
createdAt: CellCreatedAt,
|
||||||
lastEditedAt: CellLastEditedAt,
|
lastEditedAt: CellLastEditedAt,
|
||||||
lastEditedBy: CellLastEditedBy,
|
lastEditedBy: CellLastEditedBy,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconUser,
|
IconUser,
|
||||||
IconPaperclip,
|
IconPaperclip,
|
||||||
|
IconFileDescription,
|
||||||
IconCheckbox,
|
IconCheckbox,
|
||||||
IconLink,
|
IconLink,
|
||||||
IconMail,
|
IconMail,
|
||||||
@@ -35,6 +36,7 @@ const propertyTypes: {
|
|||||||
{ type: "date", icon: IconCalendar, labelKey: "Date" },
|
{ type: "date", icon: IconCalendar, labelKey: "Date" },
|
||||||
{ type: "person", icon: IconUser, labelKey: "Person" },
|
{ type: "person", icon: IconUser, labelKey: "Person" },
|
||||||
{ type: "file", icon: IconPaperclip, labelKey: "File" },
|
{ type: "file", icon: IconPaperclip, labelKey: "File" },
|
||||||
|
{ type: "page", icon: IconFileDescription, labelKey: "Page" },
|
||||||
{ type: "checkbox", icon: IconCheckbox, labelKey: "Checkbox" },
|
{ type: "checkbox", icon: IconCheckbox, labelKey: "Checkbox" },
|
||||||
{ type: "url", icon: IconLink, labelKey: "URL" },
|
{ type: "url", icon: IconLink, labelKey: "URL" },
|
||||||
{ type: "email", icon: IconMail, labelKey: "Email" },
|
{ type: "email", icon: IconMail, labelKey: "Email" },
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ function getOperatorsForType(type: string): FilterOperator[] {
|
|||||||
return ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"];
|
return ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"];
|
||||||
case "file":
|
case "file":
|
||||||
return ["isEmpty", "isNotEmpty"];
|
return ["isEmpty", "isNotEmpty"];
|
||||||
|
case "page":
|
||||||
|
return ["isEmpty", "isNotEmpty"];
|
||||||
default:
|
default:
|
||||||
return ["eq", "neq", "isEmpty", "isNotEmpty"];
|
return ["eq", "neq", "isEmpty", "isNotEmpty"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,11 @@ export function ViewSortConfigPopover({
|
|||||||
if (!opened) setDraft(null);
|
if (!opened) setDraft(null);
|
||||||
}, [opened]);
|
}, [opened]);
|
||||||
|
|
||||||
const propertyOptions = properties.map((p) => ({
|
// Page properties store a UUID; sorting by raw UUID is unhelpful and
|
||||||
|
// title-based sort would require a join. Hide until we support it properly.
|
||||||
|
const sortableProperties = properties.filter((p) => p.type !== "page");
|
||||||
|
|
||||||
|
const propertyOptions = sortableProperties.map((p) => ({
|
||||||
value: p.id,
|
value: p.id,
|
||||||
label: p.name,
|
label: p.name,
|
||||||
}));
|
}));
|
||||||
@@ -53,10 +57,10 @@ export function ViewSortConfigPopover({
|
|||||||
|
|
||||||
const handleStartDraft = useCallback(() => {
|
const handleStartDraft = useCallback(() => {
|
||||||
const usedIds = new Set(sorts.map((s) => s.propertyId));
|
const usedIds = new Set(sorts.map((s) => s.propertyId));
|
||||||
const available = properties.find((p) => !usedIds.has(p.id));
|
const available = sortableProperties.find((p) => !usedIds.has(p.id));
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
setDraft({ propertyId: available.id, direction: "asc" });
|
setDraft({ propertyId: available.id, direction: "asc" });
|
||||||
}, [sorts, properties]);
|
}, [sorts, sortableProperties]);
|
||||||
|
|
||||||
const handleSaveDraft = useCallback(() => {
|
const handleSaveDraft = useCallback(() => {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
@@ -99,7 +103,8 @@ export function ViewSortConfigPopover({
|
|||||||
[sorts, onChange],
|
[sorts, onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const canAddMore = properties.length > sorts.length + (draft ? 1 : 0);
|
const canAddMore =
|
||||||
|
sortableProperties.length > sorts.length + (draft ? 1 : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import api from "@/lib/api-client";
|
||||||
|
|
||||||
|
export type ResolvedPage = {
|
||||||
|
id: string;
|
||||||
|
slugId: string;
|
||||||
|
title: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
spaceId: string;
|
||||||
|
space: { id: string; slug: string; name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function resolvePages(pageIds: string[]): Promise<ResolvedPage[]> {
|
||||||
|
if (pageIds.length === 0) return [];
|
||||||
|
const res = await api.post<{ items: ResolvedPage[] }>(
|
||||||
|
"/bases/pages/resolve",
|
||||||
|
{ pageIds },
|
||||||
|
);
|
||||||
|
return res.data.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable, sorted, deduped list so the query key is consistent across renders
|
||||||
|
// no matter what order the caller hands us the ids in.
|
||||||
|
function normalize(ids: (string | null | undefined)[]): string[] {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const id of ids) {
|
||||||
|
if (typeof id === "string" && id.length > 0) set.add(id);
|
||||||
|
}
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PageResolution = {
|
||||||
|
// Map distinguishes three states via lookup:
|
||||||
|
// - key absent → id not requested
|
||||||
|
// - value undefined → still resolving (query pending, or stale fetch in flight)
|
||||||
|
// - value null → resolved and not accessible (deleted, restricted, cross-workspace)
|
||||||
|
// - value ResolvedPage → resolved and accessible
|
||||||
|
pages: Map<string, ResolvedPage | null | undefined>;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useResolvedPages(
|
||||||
|
pageIds: (string | null | undefined)[],
|
||||||
|
): PageResolution {
|
||||||
|
const normalized = useMemo(() => normalize(pageIds), [pageIds]);
|
||||||
|
|
||||||
|
const { data, isSuccess, isLoading } = useQuery({
|
||||||
|
queryKey: ["bases", "pages", "resolve", normalized],
|
||||||
|
queryFn: () => resolvePages(normalized),
|
||||||
|
enabled: normalized.length > 0,
|
||||||
|
staleTime: 30_000,
|
||||||
|
gcTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pages = useMemo(() => {
|
||||||
|
const map = new Map<string, ResolvedPage | null | undefined>();
|
||||||
|
// Seed with undefined (= "still resolving") until the fetch succeeds.
|
||||||
|
for (const id of normalized) map.set(id, isSuccess ? null : undefined);
|
||||||
|
for (const item of data ?? []) map.set(item.id, item);
|
||||||
|
return map;
|
||||||
|
}, [normalized, data, isSuccess]);
|
||||||
|
|
||||||
|
return { pages, isLoading };
|
||||||
|
}
|
||||||
@@ -295,3 +295,48 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagePill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--mantine-radius-sm);
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-4));
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||||
|
text-decoration: none;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagePill:hover {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagePillIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagePillIconFallback {
|
||||||
|
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagePillText {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageMissing {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type BasePropertyType =
|
|||||||
| 'date'
|
| 'date'
|
||||||
| 'person'
|
| 'person'
|
||||||
| 'file'
|
| 'file'
|
||||||
|
| 'page'
|
||||||
| 'checkbox'
|
| 'checkbox'
|
||||||
| 'url'
|
| 'url'
|
||||||
| 'email'
|
| 'email'
|
||||||
@@ -63,6 +64,8 @@ export type PersonTypeOptions = {
|
|||||||
allowMultiple?: boolean;
|
allowMultiple?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PageTypeOptions = Record<string, never>;
|
||||||
|
|
||||||
export type TypeOptions =
|
export type TypeOptions =
|
||||||
| SelectTypeOptions
|
| SelectTypeOptions
|
||||||
| NumberTypeOptions
|
| NumberTypeOptions
|
||||||
@@ -72,6 +75,7 @@ export type TypeOptions =
|
|||||||
| UrlTypeOptions
|
| UrlTypeOptions
|
||||||
| EmailTypeOptions
|
| EmailTypeOptions
|
||||||
| PersonTypeOptions
|
| PersonTypeOptions
|
||||||
|
| PageTypeOptions
|
||||||
| Record<string, unknown>;
|
| Record<string, unknown>;
|
||||||
|
|
||||||
export type IBaseProperty = {
|
export type IBaseProperty = {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { BasePropertyService } from './services/base-property.service';
|
|||||||
import { BaseRowService } from './services/base-row.service';
|
import { BaseRowService } from './services/base-row.service';
|
||||||
import { BaseViewService } from './services/base-view.service';
|
import { BaseViewService } from './services/base-view.service';
|
||||||
import { BaseCsvExportService } from './services/base-csv-export.service';
|
import { BaseCsvExportService } from './services/base-csv-export.service';
|
||||||
|
import { BasePageResolverService } from './services/base-page-resolver.service';
|
||||||
import { BaseQueueProcessor } from './processors/base-queue.processor';
|
import { BaseQueueProcessor } from './processors/base-queue.processor';
|
||||||
import { BaseWsService } from './realtime/base-ws.service';
|
import { BaseWsService } from './realtime/base-ws.service';
|
||||||
import { BaseWsConsumers } from './realtime/base-ws-consumers';
|
import { BaseWsConsumers } from './realtime/base-ws-consumers';
|
||||||
@@ -29,6 +30,7 @@ import { QueueName } from '../../integrations/queue/constants';
|
|||||||
BaseRowService,
|
BaseRowService,
|
||||||
BaseViewService,
|
BaseViewService,
|
||||||
BaseCsvExportService,
|
BaseCsvExportService,
|
||||||
|
BasePageResolverService,
|
||||||
BaseQueueProcessor,
|
BaseQueueProcessor,
|
||||||
BasePresenceService,
|
BasePresenceService,
|
||||||
BaseWsService,
|
BaseWsService,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const BasePropertyType = {
|
|||||||
DATE: 'date',
|
DATE: 'date',
|
||||||
PERSON: 'person',
|
PERSON: 'person',
|
||||||
FILE: 'file',
|
FILE: 'file',
|
||||||
|
PAGE: 'page',
|
||||||
CHECKBOX: 'checkbox',
|
CHECKBOX: 'checkbox',
|
||||||
URL: 'url',
|
URL: 'url',
|
||||||
EMAIL: 'email',
|
EMAIL: 'email',
|
||||||
@@ -114,6 +115,7 @@ const typeOptionsSchemaMap: Record<BasePropertyTypeValue, z.ZodType> = {
|
|||||||
[BasePropertyType.DATE]: dateTypeOptionsSchema,
|
[BasePropertyType.DATE]: dateTypeOptionsSchema,
|
||||||
[BasePropertyType.PERSON]: personTypeOptionsSchema,
|
[BasePropertyType.PERSON]: personTypeOptionsSchema,
|
||||||
[BasePropertyType.FILE]: emptyTypeOptionsSchema,
|
[BasePropertyType.FILE]: emptyTypeOptionsSchema,
|
||||||
|
[BasePropertyType.PAGE]: emptyTypeOptionsSchema,
|
||||||
[BasePropertyType.CHECKBOX]: checkboxTypeOptionsSchema,
|
[BasePropertyType.CHECKBOX]: checkboxTypeOptionsSchema,
|
||||||
[BasePropertyType.URL]: urlTypeOptionsSchema,
|
[BasePropertyType.URL]: urlTypeOptionsSchema,
|
||||||
[BasePropertyType.EMAIL]: emailTypeOptionsSchema,
|
[BasePropertyType.EMAIL]: emailTypeOptionsSchema,
|
||||||
@@ -159,6 +161,7 @@ const cellValueSchemaMap: Partial<Record<BasePropertyTypeValue, z.ZodType>> = {
|
|||||||
fileSize: z.number().optional(),
|
fileSize: z.number().optional(),
|
||||||
filePath: z.string().optional(),
|
filePath: z.string().optional(),
|
||||||
})),
|
})),
|
||||||
|
[BasePropertyType.PAGE]: z.uuid(),
|
||||||
[BasePropertyType.CHECKBOX]: z.boolean(),
|
[BasePropertyType.CHECKBOX]: z.boolean(),
|
||||||
[BasePropertyType.URL]: z.url(),
|
[BasePropertyType.URL]: z.url(),
|
||||||
[BasePropertyType.EMAIL]: z.email(),
|
[BasePropertyType.EMAIL]: z.email(),
|
||||||
@@ -192,6 +195,7 @@ export type CellConversionContext = {
|
|||||||
fromTypeOptions?: unknown;
|
fromTypeOptions?: unknown;
|
||||||
userNames?: Map<string, string>;
|
userNames?: Map<string, string>;
|
||||||
attachmentNames?: Map<string, string>;
|
attachmentNames?: Map<string, string>;
|
||||||
|
pageTitles?: Map<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveChoiceName(
|
function resolveChoiceName(
|
||||||
@@ -256,6 +260,16 @@ export function attemptCellConversion(
|
|||||||
.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
||||||
return { converted: true, value: parts.join(', ') };
|
return { converted: true, value: parts.join(', ') };
|
||||||
}
|
}
|
||||||
|
if (fromType === BasePropertyType.PAGE && typeof value === 'string') {
|
||||||
|
const title = ctx.pageTitles?.get(value);
|
||||||
|
return { converted: true, value: title ?? '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page cells only accept a page UUID. Free text / other IDs can't be
|
||||||
|
// coerced into a valid page reference — drop to null.
|
||||||
|
if (toType === BasePropertyType.PAGE && fromType !== BasePropertyType.PAGE) {
|
||||||
|
return { converted: true, value: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetSchema = cellValueSchemaMap[toType];
|
const targetSchema = cellValueSchemaMap[toType];
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import {
|
|||||||
import { FastifyReply } from 'fastify';
|
import { FastifyReply } from 'fastify';
|
||||||
import { BaseService } from '../services/base.service';
|
import { BaseService } from '../services/base.service';
|
||||||
import { BaseCsvExportService } from '../services/base-csv-export.service';
|
import { BaseCsvExportService } from '../services/base-csv-export.service';
|
||||||
|
import { BasePageResolverService } from '../services/base-page-resolver.service';
|
||||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||||
import { CreateBaseDto } from '../dto/create-base.dto';
|
import { CreateBaseDto } from '../dto/create-base.dto';
|
||||||
import { UpdateBaseDto } from '../dto/update-base.dto';
|
import { UpdateBaseDto } from '../dto/update-base.dto';
|
||||||
import { BaseIdDto } from '../dto/base.dto';
|
import { BaseIdDto } from '../dto/base.dto';
|
||||||
import { ExportBaseCsvDto } from '../dto/export-base.dto';
|
import { ExportBaseCsvDto } from '../dto/export-base.dto';
|
||||||
|
import { ResolvePagesDto } from '../dto/resolve-pages.dto';
|
||||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
||||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||||
@@ -35,6 +37,7 @@ export class BaseController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly baseService: BaseService,
|
private readonly baseService: BaseService,
|
||||||
private readonly baseCsvExportService: BaseCsvExportService,
|
private readonly baseCsvExportService: BaseCsvExportService,
|
||||||
|
private readonly basePageResolverService: BasePageResolverService,
|
||||||
private readonly baseRepo: BaseRepo,
|
private readonly baseRepo: BaseRepo,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
) {}
|
) {}
|
||||||
@@ -138,4 +141,19 @@ export class BaseController {
|
|||||||
res,
|
res,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('pages/resolve')
|
||||||
|
async resolvePages(
|
||||||
|
@Body() dto: ResolvePagesDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const items = await this.basePageResolverService.resolvePages(
|
||||||
|
dto.pageIds,
|
||||||
|
workspace.id,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class ResolvePagesDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayMaxSize(100)
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
pageIds: string[];
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export const PropertyKind = {
|
|||||||
MULTI: 'multi',
|
MULTI: 'multi',
|
||||||
PERSON: 'person',
|
PERSON: 'person',
|
||||||
FILE: 'file',
|
FILE: 'file',
|
||||||
|
PAGE: 'page',
|
||||||
SYS_USER: 'sys_user',
|
SYS_USER: 'sys_user',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@ export function propertyKind(type: string): PropertyKindValue | null {
|
|||||||
return PropertyKind.PERSON;
|
return PropertyKind.PERSON;
|
||||||
case BasePropertyType.FILE:
|
case BasePropertyType.FILE:
|
||||||
return PropertyKind.FILE;
|
return PropertyKind.FILE;
|
||||||
|
case BasePropertyType.PAGE:
|
||||||
|
return PropertyKind.PAGE;
|
||||||
case BasePropertyType.LAST_EDITED_BY:
|
case BasePropertyType.LAST_EDITED_BY:
|
||||||
return PropertyKind.SYS_USER;
|
return PropertyKind.SYS_USER;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ function buildCondition(
|
|||||||
return personCondition(eb, cond, prop);
|
return personCondition(eb, cond, prop);
|
||||||
case PropertyKind.FILE:
|
case PropertyKind.FILE:
|
||||||
return arrayOfIdsCondition(eb, cond);
|
return arrayOfIdsCondition(eb, cond);
|
||||||
|
case PropertyKind.PAGE:
|
||||||
|
return pageCondition(eb, cond);
|
||||||
default:
|
default:
|
||||||
return FALSE;
|
return FALSE;
|
||||||
}
|
}
|
||||||
@@ -292,6 +294,48 @@ function personCondition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pageCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||||
|
// Page cells store a single page uuid as text. Shape matches selectCondition.
|
||||||
|
const expr = textCell(cond.propertyId);
|
||||||
|
const val = cond.value;
|
||||||
|
switch (cond.op) {
|
||||||
|
case 'isEmpty':
|
||||||
|
return eb.or([
|
||||||
|
eb(expr as any, 'is', null),
|
||||||
|
eb(expr as any, '=', ''),
|
||||||
|
]);
|
||||||
|
case 'isNotEmpty':
|
||||||
|
return eb.and([
|
||||||
|
eb(expr as any, 'is not', null),
|
||||||
|
eb(expr as any, '!=', ''),
|
||||||
|
]);
|
||||||
|
case 'eq':
|
||||||
|
return val == null ? FALSE : eb(expr as any, '=', String(val));
|
||||||
|
case 'neq':
|
||||||
|
return val == null
|
||||||
|
? FALSE
|
||||||
|
: eb.or([
|
||||||
|
eb(expr as any, 'is', null),
|
||||||
|
eb(expr as any, '!=', String(val)),
|
||||||
|
]);
|
||||||
|
case 'any': {
|
||||||
|
const arr = asStringArray(val);
|
||||||
|
if (arr.length === 0) return FALSE;
|
||||||
|
return eb(expr as any, 'in', arr);
|
||||||
|
}
|
||||||
|
case 'none': {
|
||||||
|
const arr = asStringArray(val);
|
||||||
|
if (arr.length === 0) return TRUE;
|
||||||
|
return eb.or([
|
||||||
|
eb(expr as any, 'is', null),
|
||||||
|
eb(expr as any, 'not in', arr),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function arrayOfIdsCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
function arrayOfIdsCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||||
const expr = arrayCell(cond.propertyId);
|
const expr = arrayCell(cond.propertyId);
|
||||||
const val = cond.value;
|
const val = cond.value;
|
||||||
|
|||||||
@@ -87,4 +87,16 @@ describe('serializeCellForCsv', () => {
|
|||||||
expect(serializeCellForCsv(prop, 'u2', { userNames })).toBe('Bob');
|
expect(serializeCellForCsv(prop, 'u2', { userNames })).toBe('Bob');
|
||||||
expect(serializeCellForCsv(prop, 'missing', { userNames })).toBe('');
|
expect(serializeCellForCsv(prop, 'missing', { userNames })).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('page resolves via pageTitles', () => {
|
||||||
|
const pageTitles = new Map([
|
||||||
|
['p1', 'Launch plan'],
|
||||||
|
['p2', 'Retro notes'],
|
||||||
|
]);
|
||||||
|
const prop = p(BasePropertyType.PAGE);
|
||||||
|
expect(serializeCellForCsv(prop, 'p1', { pageTitles })).toBe('Launch plan');
|
||||||
|
expect(serializeCellForCsv(prop, 'missing', { pageTitles })).toBe('');
|
||||||
|
expect(serializeCellForCsv(prop, 'p1', {})).toBe('');
|
||||||
|
expect(serializeCellForCsv(prop, 123, { pageTitles })).toBe('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BasePropertyType, BasePropertyTypeValue } from '../base.schemas';
|
|||||||
|
|
||||||
export type CellCsvContext = {
|
export type CellCsvContext = {
|
||||||
userNames?: Map<string, string>;
|
userNames?: Map<string, string>;
|
||||||
|
pageTitles?: Map<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PropertyLike = {
|
type PropertyLike = {
|
||||||
@@ -81,6 +82,10 @@ export function serializeCellForCsv(
|
|||||||
case BasePropertyType.LAST_EDITED_BY:
|
case BasePropertyType.LAST_EDITED_BY:
|
||||||
return resolveUser(value, ctx);
|
return resolveUser(value, ctx);
|
||||||
|
|
||||||
|
case BasePropertyType.PAGE:
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
return ctx.pageTitles?.get(value) ?? '';
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,40 +138,68 @@ export class BaseCsvExportService {
|
|||||||
chunk: Array<{ cells: unknown; lastUpdatedById: string | null }>,
|
chunk: Array<{ cells: unknown; lastUpdatedById: string | null }>,
|
||||||
properties: Array<{ id: string; type: string }>,
|
properties: Array<{ id: string; type: string }>,
|
||||||
): Promise<CellCsvContext> {
|
): Promise<CellCsvContext> {
|
||||||
|
const ctx: CellCsvContext = {};
|
||||||
|
|
||||||
const needsUsers = properties.some(
|
const needsUsers = properties.some(
|
||||||
(p) =>
|
(p) =>
|
||||||
p.type === BasePropertyType.PERSON ||
|
p.type === BasePropertyType.PERSON ||
|
||||||
p.type === BasePropertyType.LAST_EDITED_BY,
|
p.type === BasePropertyType.LAST_EDITED_BY,
|
||||||
);
|
);
|
||||||
if (!needsUsers) return {};
|
|
||||||
|
|
||||||
const userIds = new Set<string>();
|
if (needsUsers) {
|
||||||
const personPropIds = properties
|
const userIds = new Set<string>();
|
||||||
.filter((p) => p.type === BasePropertyType.PERSON)
|
const personPropIds = properties
|
||||||
.map((p) => p.id);
|
.filter((p) => p.type === BasePropertyType.PERSON)
|
||||||
|
.map((p) => p.id);
|
||||||
|
|
||||||
for (const row of chunk) {
|
for (const row of chunk) {
|
||||||
if (row.lastUpdatedById) userIds.add(row.lastUpdatedById);
|
if (row.lastUpdatedById) userIds.add(row.lastUpdatedById);
|
||||||
const cells = (row.cells ?? {}) as Record<string, unknown>;
|
const cells = (row.cells ?? {}) as Record<string, unknown>;
|
||||||
for (const pid of personPropIds) {
|
for (const pid of personPropIds) {
|
||||||
const v = cells[pid];
|
const v = cells[pid];
|
||||||
if (typeof v === 'string') userIds.add(v);
|
if (typeof v === 'string') userIds.add(v);
|
||||||
else if (Array.isArray(v)) {
|
else if (Array.isArray(v)) {
|
||||||
for (const id of v) if (typeof id === 'string') userIds.add(id);
|
for (const id of v) if (typeof id === 'string') userIds.add(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userIds.size > 0) {
|
||||||
|
const rows = await this.db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id', 'name', 'email'])
|
||||||
|
.where('id', 'in', Array.from(userIds))
|
||||||
|
.execute();
|
||||||
|
ctx.userNames = new Map(
|
||||||
|
rows.map((u) => [u.id, u.name || u.email || '']),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userIds.size === 0) return {};
|
const pagePropIds = properties
|
||||||
|
.filter((p) => p.type === BasePropertyType.PAGE)
|
||||||
|
.map((p) => p.id);
|
||||||
|
|
||||||
const rows = await this.db
|
if (pagePropIds.length > 0) {
|
||||||
.selectFrom('users')
|
const pageIds = new Set<string>();
|
||||||
.select(['id', 'name', 'email'])
|
for (const row of chunk) {
|
||||||
.where('id', 'in', Array.from(userIds))
|
const cells = (row.cells ?? {}) as Record<string, unknown>;
|
||||||
.execute();
|
for (const pid of pagePropIds) {
|
||||||
|
const v = cells[pid];
|
||||||
|
if (typeof v === 'string' && v.length > 0) pageIds.add(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
if (pageIds.size > 0) {
|
||||||
userNames: new Map(rows.map((u) => [u.id, u.name || u.email || ''])),
|
const rows = await this.db
|
||||||
};
|
.selectFrom('pages')
|
||||||
|
.select(['id', 'title'])
|
||||||
|
.where('id', 'in', Array.from(pageIds))
|
||||||
|
.execute();
|
||||||
|
ctx.pageTitles = new Map(rows.map((p) => [p.id, p.title ?? '']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||||
|
|
||||||
|
export type ResolvedPage = {
|
||||||
|
id: string;
|
||||||
|
slugId: string;
|
||||||
|
title: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
spaceId: string;
|
||||||
|
space: { id: string; slug: string; name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BasePageResolverService {
|
||||||
|
constructor(
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async resolvePages(
|
||||||
|
pageIds: string[],
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<ResolvedPage[]> {
|
||||||
|
const unique = Array.from(new Set(pageIds));
|
||||||
|
if (unique.length === 0) return [];
|
||||||
|
|
||||||
|
const rows = await this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select([
|
||||||
|
'pages.id',
|
||||||
|
'pages.slugId',
|
||||||
|
'pages.title',
|
||||||
|
'pages.icon',
|
||||||
|
'pages.spaceId',
|
||||||
|
])
|
||||||
|
.select((eb) =>
|
||||||
|
jsonObjectFrom(
|
||||||
|
eb
|
||||||
|
.selectFrom('spaces')
|
||||||
|
.select(['spaces.id', 'spaces.name', 'spaces.slug'])
|
||||||
|
.whereRef('spaces.id', '=', 'pages.spaceId'),
|
||||||
|
).as('space'),
|
||||||
|
)
|
||||||
|
.where('pages.id', 'in', unique)
|
||||||
|
.where('pages.workspaceId', '=', workspaceId)
|
||||||
|
.where('pages.deletedAt', 'is', null)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
|
||||||
|
const accessible = await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||||
|
pageIds: rows.map((r) => r.id),
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
const accessibleSet = new Set(accessible);
|
||||||
|
|
||||||
|
return rows.filter((r) => accessibleSet.has(r.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,6 +159,16 @@ async function buildCtx(
|
|||||||
.execute();
|
.execute();
|
||||||
ctx.attachmentNames = new Map(rows.map((a) => [a.id, a.fileName]));
|
ctx.attachmentNames = new Map(rows.map((a) => [a.id, a.fileName]));
|
||||||
}
|
}
|
||||||
|
} else if (fromType === BasePropertyType.PAGE) {
|
||||||
|
const ids = collectIds(chunk, propertyId);
|
||||||
|
if (ids.size > 0) {
|
||||||
|
const rows = await db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['id', 'title'])
|
||||||
|
.where('id', 'in', Array.from(ids))
|
||||||
|
.execute();
|
||||||
|
ctx.pageTitles = new Map(rows.map((p) => [p.id, p.title ?? '']));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx;
|
return ctx;
|
||||||
|
|||||||
Reference in New Issue
Block a user