page property

This commit is contained in:
Philipinho
2026-04-20 21:27:29 +01:00
parent 30988c1959
commit 9ecf88511b
19 changed files with 627 additions and 26 deletions
@@ -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 { CellPerson } from "@/features/base/components/cells/cell-person";
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 { CellLastEditedAt } from "@/features/base/components/cells/cell-last-edited-at";
import { CellLastEditedBy } from "@/features/base/components/cells/cell-last-edited-by";
@@ -45,6 +46,7 @@ const cellComponents: Record<
email: CellEmail,
person: CellPerson,
file: CellFile,
page: CellPage,
createdAt: CellCreatedAt,
lastEditedAt: CellLastEditedAt,
lastEditedBy: CellLastEditedBy,
@@ -8,6 +8,7 @@ import {
IconCalendar,
IconUser,
IconPaperclip,
IconFileDescription,
IconCheckbox,
IconLink,
IconMail,
@@ -35,6 +36,7 @@ const propertyTypes: {
{ type: "date", icon: IconCalendar, labelKey: "Date" },
{ type: "person", icon: IconUser, labelKey: "Person" },
{ type: "file", icon: IconPaperclip, labelKey: "File" },
{ type: "page", icon: IconFileDescription, labelKey: "Page" },
{ type: "checkbox", icon: IconCheckbox, labelKey: "Checkbox" },
{ type: "url", icon: IconLink, labelKey: "URL" },
{ type: "email", icon: IconMail, labelKey: "Email" },
@@ -65,6 +65,8 @@ function getOperatorsForType(type: string): FilterOperator[] {
return ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"];
case "file":
return ["isEmpty", "isNotEmpty"];
case "page":
return ["isEmpty", "isNotEmpty"];
default:
return ["eq", "neq", "isEmpty", "isNotEmpty"];
}
@@ -41,7 +41,11 @@ export function ViewSortConfigPopover({
if (!opened) setDraft(null);
}, [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,
label: p.name,
}));
@@ -53,10 +57,10 @@ export function ViewSortConfigPopover({
const handleStartDraft = useCallback(() => {
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;
setDraft({ propertyId: available.id, direction: "asc" });
}, [sorts, properties]);
}, [sorts, sortableProperties]);
const handleSaveDraft = useCallback(() => {
if (!draft) return;
@@ -99,7 +103,8 @@ export function ViewSortConfigPopover({
[sorts, onChange],
);
const canAddMore = properties.length > sorts.length + (draft ? 1 : 0);
const canAddMore =
sortableProperties.length > sorts.length + (draft ? 1 : 0);
return (
<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;
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'
| 'person'
| 'file'
| 'page'
| 'checkbox'
| 'url'
| 'email'
@@ -63,6 +64,8 @@ export type PersonTypeOptions = {
allowMultiple?: boolean;
};
export type PageTypeOptions = Record<string, never>;
export type TypeOptions =
| SelectTypeOptions
| NumberTypeOptions
@@ -72,6 +75,7 @@ export type TypeOptions =
| UrlTypeOptions
| EmailTypeOptions
| PersonTypeOptions
| PageTypeOptions
| Record<string, unknown>;
export type IBaseProperty = {