feat(ee): bases (#2295)

* feat(ee): bases
Table and kanban UI, formula engine package, and the base-embed editor extension.

* - default status
- type fix
- error helper

* fix: base trash list handling

* feat: base nodeview menu

* feat: translation

* fix number precision

* feat(base): add focused-cell atom and cell coordinate types

* feat(base): add cell focus-ring style

* feat(base): add pure next-cell navigation helper

* feat(base): keyboard navigation controller and grid wiring

* update offerings

* feat(base): cell focus ring, click-to-focus, and gridcell ARIA

* feat(base): row ARIA index and selected state

* feat(base): seed editor value on type-to-edit for free-text cells

* feat(base): make column headers keyboard-focusable as tab stops

* fix(base): remove focus outline on grid container

* fix(base): show cell focus ring only while the grid is focused

* feat(base): keyboard-navigate the row-number column for selection

* fix(base): sync header/body horizontal scroll on header focus; expand row via Space, drop expander from tab order

* fix(base): tab from long-text editor moves to next cell instead of leaving the table

* fix(base): close view popovers on Escape regardless of focus; drop redundant property switch tab stop

* fix(base): show cell focus ring only while the grid body itself is focused

* fix(base): render view-tab rename as an inline pill so the tab band height stays put

* fix(base): refer to the feature as 'base' rather than 'database'

* fix: change permissions object shape

* license file

* fix tsconfig

* fix base cache

* fix: preserve sidebar title/icon on partial page updates

* fix: skip duplicate row fetch when opening new kanban card

* fix refetch

* fix focus

* fix spacing

* fix(base): select grid cell on mousedown to avoid stale focus ring flash

The focus ring is gated on the grid having DOM focus (.bodyGrid:focus .cellFocused), but the focusedCell atom is never cleared when the grid blurs. Clicking outside hides the ring via the :focus gate while the atom still points at the old cell.

Selection was committed on click (mouseup), while the grid receives focus on mousedown. Clicking a new cell re-focused the grid before the atom updated, briefly painting the ring on the previously selected cell. Commit selection on mousedown so the atom updates in the same event that grants focus, before the browser paints.

* fix: activate New row button via keyboard (Enter/Space)

The New row control is a role=button div with no keydown handler, so Enter/Space never triggered it. It also lives inside the grid element, whose native keydown listener caught the Enter and ran cell navigation against the previously focused cell.

Add Enter/Space activation to the button, and make the grid keyboard handler ignore keydowns that originate from a focusable child rather than the grid element itself, so in-grid controls handle their own keys.

* fix(base): keep add-property popover within viewport on mobile

Opened from the row detail modal, the create-property popover anchors to the bottom Add property button and flips upward on small screens, clipping its top (name field, formula editor) off-screen with no way to scroll to it.

Bound the dropdown to the available height with the floating-ui size middleware and give it an internal scroll container. Disable react-remove-scroll isolation on the modal so the body-portaled popover can scroll on touch while the modal scroll lock stays active.

* fix(base): enable grid cell editing on touch devices

Cells could only enter edit mode via double-click or a physical keyboard,
so touch devices had no way to edit a cell. Treat a touch/pen tap as the
edit gesture, distinguishing a tap from a scroll by movement and branching
per pointer type so mouse double-click stays unchanged. Also reveal the
row expand button on hover-less devices so the row detail view stays
reachable.

* feat(editor): add base and kanban inserts to the toolbar

* feat(base): insert row below via Shift+Enter on the primary cell

* fix(base): place caret at end instead of selecting all when editing cells

* fix(base): prevent popover inputs from losing focus on mobile in row detail modal

* fix grid cells on mobile

* sync

* fix: read-only export

* feat(base): add prefixed nanoid id schemas and generators

* feat(base): enforce strict property/choice id validation

* feat(base): make property id varchar with per-base composite pk

* feat(base): pass property id as text to cell extractors

* feat(base): scope property lookups per base and generate property ids in repo

* feat(base): generate status template choice ids as nanoid

* feat(base): generate choice ids as nanoid on the client

* chore(base): seed choice ids with nanoid

* fix(base): mint kanban choice ids as nanoid

* sync

* sync

* sync
This commit is contained in:
Philip Okugbe
2026-06-21 03:07:14 +01:00
committed by GitHub
parent e0fdc0c178
commit 89f13c3fbc
249 changed files with 23435 additions and 183 deletions
@@ -0,0 +1,100 @@
import { ReactElement, useLayoutEffect, useRef, useState } from "react";
import { Tooltip } from "@mantine/core";
import cellClasses from "@/ee/base/styles/cells.module.css";
export function computeVisibleBadgeCount(
itemWidths: number[],
gap: number,
available: number,
badgeWidth: number,
): number {
const count = itemWidths.length;
if (count === 0) return 0;
if (available <= 0) return count;
let lineWidth = 0;
for (let i = 0; i < count; i++) {
lineWidth += itemWidths[i] + (i > 0 ? gap : 0);
}
if (lineWidth <= available) return count;
let used = 0;
let fit = 0;
for (let i = 0; i < count; i++) {
const advance = itemWidths[i] + (i > 0 ? gap : 0);
if (used + advance + gap + badgeWidth <= available) {
used += advance;
fit = i + 1;
} else {
break;
}
}
return Math.max(fit, 1);
}
const BADGE_GAP = 4;
type BadgeOverflowListProps = {
chips: ReactElement[];
measureKey: string;
tooltipLabel?: string;
};
export function BadgeOverflowList({
chips,
measureKey,
tooltipLabel,
}: BadgeOverflowListProps) {
const containerRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(chips.length);
useLayoutEffect(() => {
const container = containerRef.current;
const measure = measureRef.current;
if (!container || !measure) return;
const recompute = () => {
const nodes = Array.from(measure.children) as HTMLElement[];
const chipWidths = nodes.slice(0, -1).map((n) => n.offsetWidth);
const badgeWidth = nodes[nodes.length - 1]?.offsetWidth ?? 0;
setVisibleCount(
computeVisibleBadgeCount(
chipWidths,
BADGE_GAP,
container.clientWidth,
badgeWidth,
),
);
};
recompute();
const observer = new ResizeObserver(recompute);
observer.observe(container);
return () => observer.disconnect();
}, [measureKey]);
const visible = chips.slice(0, visibleCount);
const overflow = chips.length - visibleCount;
return (
<Tooltip
label={tooltipLabel}
multiline
withinPortal
openDelay={400}
disabled={!tooltipLabel || overflow <= 0}
>
<div className={cellClasses.badgeGroup} ref={containerRef}>
<div className={cellClasses.badgeMeasure} ref={measureRef} aria-hidden>
{chips}
<span className={cellClasses.overflowCount}>+{chips.length}</span>
</div>
{visible}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}
</div>
</Tooltip>
);
}
@@ -0,0 +1,44 @@
import { useCallback } from "react";
import { Checkbox } from "@mantine/core";
import { IBaseProperty } from "@/ee/base/types/base.types";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellCheckboxProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
readOnly?: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellCheckbox({ value, readOnly, onCommit }: CellCheckboxProps) {
const checked = value === true;
const handleChange = useCallback(() => {
if (readOnly) return;
onCommit(!checked);
}, [readOnly, checked, onCommit]);
return (
<div
className={cellClasses.checkboxCell}
onClick={handleChange}
style={readOnly ? { cursor: "default" } : undefined}
>
<Checkbox
checked={checked}
onChange={() => {}}
size="xs"
tabIndex={-1}
styles={{
input: {
cursor: readOnly ? "default" : "pointer",
pointerEvents: "none",
},
}}
/>
</div>
);
}
@@ -0,0 +1,22 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { formatTimestamp } from "@/ee/base/formatters/cell-formatters";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellCreatedAtProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellCreatedAt({ value }: CellCreatedAtProps) {
const formatted = formatTimestamp(typeof value === "string" ? value : null);
if (!formatted) {
return <span className={cellClasses.emptyValue} />;
}
return <span className={cellClasses.dateValue}>{formatted}</span>;
}
@@ -0,0 +1,146 @@
import { useCallback } from "react";
import { Popover } from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import {
IBaseProperty,
DateTypeOptions,
} from "@/ee/base/types/base.types";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellDateProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function formatDateDisplay(
dateStr: string | null | undefined,
options: DateTypeOptions | undefined,
): string {
if (!dateStr) return "";
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return "";
const months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const month = months[date.getMonth()];
const day = date.getDate();
const year = date.getFullYear();
let result = `${month} ${day}, ${year}`;
if (options?.includeTime) {
if (options.timeFormat === "24h") {
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
result += ` ${hours}:${minutes}`;
} else {
let hours = date.getHours();
const ampm = hours >= 12 ? "PM" : "AM";
hours = hours % 12 || 12;
const minutes = String(date.getMinutes()).padStart(2, "0");
result += ` ${hours}:${minutes} ${ampm}`;
}
}
return result;
} catch {
return "";
}
}
function toISODateString(dateStr: string | null): string | null {
if (!dateStr) return null;
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return null;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
} catch {
return null;
}
}
export function CellDate({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellDateProps) {
const typeOptions = property.typeOptions as DateTypeOptions | undefined;
const dateStr = typeof value === "string" ? value : null;
const pickerValue = toISODateString(dateStr);
const handleChange = useCallback(
(selected: string | null) => {
if (selected) {
const date = new Date(selected);
onCommit(date.toISOString());
} else {
onCommit(null);
}
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
if (isEditing) {
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width="auto"
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
<span className={cellClasses.dateValue}>
{formatDateDisplay(dateStr, typeOptions)}
</span>
</div>
</Popover.Target>
<Popover.Dropdown p="xs" onKeyDown={handleKeyDown}>
<DatePicker
value={pickerValue}
onChange={handleChange}
size="sm"
/>
</Popover.Dropdown>
</Popover>
);
}
if (!dateStr) {
return <span className={cellClasses.emptyValue} />;
}
return (
<span className={cellClasses.dateValue}>
{formatDateDisplay(dateStr, typeOptions)}
</span>
);
}
@@ -0,0 +1,61 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { Tooltip } from "@mantine/core";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellEmailProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft || null;
export function CellEmail({ value, property, rowId, isEditing, onCommit, onCancel }: CellEmailProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
<input
ref={inputRef}
type="email"
className={cellClasses.cellInput}
value={draft}
placeholder="email@example.com"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const displayValue = toDraft(value);
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
<a
className={cellClasses.emailLink}
href={`mailto:${displayValue}`}
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
</Tooltip>
);
}
@@ -0,0 +1,236 @@
import { useState, useRef, useCallback } from "react";
import { Popover, ActionIcon, Text, UnstyledButton } from "@mantine/core";
import {
IconPaperclip,
IconUpload,
IconFile,
IconX,
} from "@tabler/icons-react";
import { IBaseProperty } from "@/ee/base/types/base.types";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { uploadFile } from "@/features/page/services/page-service";
import { getFileUrl } from "@/lib/config";
export type FileValue = {
id: string;
fileName: string;
mimeType?: string;
fileSize?: number;
url?: string;
};
function buildFileUrl(file: Pick<FileValue, "id" | "fileName" | "url">): string {
return file.url ?? `/api/files/${file.id}/${encodeURIComponent(file.fileName)}`;
}
type CellFileProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
readOnly?: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatFileSize(bytes?: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function parseFiles(value: unknown): FileValue[] {
if (!Array.isArray(value)) return [];
return value.filter(
(f): f is FileValue =>
f && typeof f === "object" && "id" in f && "fileName" in f,
);
}
export function CellFile({
value,
property,
isEditing,
readOnly,
onCommit,
onCancel,
}: CellFileProps) {
const files = parseFiles(value);
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleRemove = useCallback(
(fileId: string) => {
if (readOnly) return;
const updated = files.filter((f) => f.id !== fileId);
onCommit(updated.length > 0 ? updated : null);
},
[readOnly, files, onCommit],
);
const handleUpload = useCallback(
async (fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return;
setUploading(true);
const newFiles: FileValue[] = [...files];
// Reuse the page-attachment upload pipeline: the base's pageId is passed
// to the standard /files/upload endpoint, which enforces the same edit
// access check as any other page attachment.
for (const file of Array.from(fileList)) {
try {
const attachment = await uploadFile(file, property.pageId);
newFiles.push({
id: attachment.id,
fileName: attachment.fileName,
mimeType: attachment.mimeType,
fileSize: attachment.fileSize,
url: `/api/files/${attachment.id}/${encodeURIComponent(attachment.fileName)}`,
});
} catch (err) {
console.error("File upload failed:", err);
}
}
setUploading(false);
onCommit(newFiles.length > 0 ? newFiles : null);
},
[files, property.pageId, onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
const MAX_VISIBLE = 2;
if (isEditing) {
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={280}
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
<FileList files={files} maxVisible={MAX_VISIBLE} />
</div>
</Popover.Target>
<Popover.Dropdown p={8} onKeyDown={handleKeyDown}>
{!readOnly && files.length === 0 && !uploading && (
<Text size="xs" c="dimmed" mb={8}>
No files attached
</Text>
)}
{files.map((file) => (
<div key={file.id} className={cellClasses.fileItemRow}>
<IconFile size={14} className={cellClasses.fileItemIcon} />
<a
href={getFileUrl(buildFileUrl(file))}
target="_blank"
rel="noreferrer"
className={cellClasses.fileItemLink}
>
<Text size="xs" truncate="end" fw={500}>
{file.fileName}
</Text>
{file.fileSize != null && (
<Text size="xs" c="dimmed">
{formatFileSize(file.fileSize)}
</Text>
)}
</a>
{!readOnly && (
<ActionIcon
variant="subtle"
color="gray"
size="xs"
onClick={() => handleRemove(file.id)}
>
<IconX size={12} />
</ActionIcon>
)}
</div>
))}
{!readOnly && (
<>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => {
handleUpload(e.target.files);
e.target.value = "";
}}
/>
<UnstyledButton
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className={cellClasses.fileUploadBtn}
style={{
color: uploading
? "var(--mantine-color-gray-5)"
: "var(--mantine-color-blue-6)",
}}
>
<IconUpload size={14} />
{uploading ? "Uploading..." : "Add file"}
</UnstyledButton>
</>
)}
</Popover.Dropdown>
</Popover>
);
}
if (files.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
return <FileList files={files} maxVisible={MAX_VISIBLE} />;
}
function FileList({
files,
maxVisible,
}: {
files: FileValue[];
maxVisible: number;
}) {
const visible = files.slice(0, maxVisible);
const overflow = files.length - maxVisible;
return (
<div className={cellClasses.fileGroup}>
{visible.map((file) => (
<span key={file.id} className={cellClasses.fileBadge}>
<IconPaperclip size={12} />
{file.fileName}
</span>
))}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}
</div>
);
}
@@ -0,0 +1,38 @@
import { Badge, Tooltip } from "@mantine/core";
import {
IBaseProperty,
isFormulaErrorCell,
} from "@/ee/base/types/base.types";
import { CellText } from "./cell-text";
import { CellNumber } from "./cell-number";
import { CellCheckbox } from "./cell-checkbox";
import { CellDate } from "./cell-date";
type Props = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellFormula(props: Props) {
const { value, property } = props;
if (isFormulaErrorCell(value)) {
return (
<Tooltip label={`${value.__err}: ${value.msg}`}>
<Badge color="red" variant="light" size="sm">
#ERROR
</Badge>
</Tooltip>
);
}
const opts = (property.typeOptions ?? {}) as { resultType?: string };
const resultType = opts.resultType ?? "null";
const readOnlyProps = { ...props, isEditing: false };
if (resultType === "number") return <CellNumber {...readOnlyProps} />;
if (resultType === "boolean") return <CellCheckbox {...readOnlyProps} />;
if (resultType === "date") return <CellDate {...readOnlyProps} />;
return <CellText {...readOnlyProps} />;
}
@@ -0,0 +1,22 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { formatTimestamp } from "@/ee/base/formatters/cell-formatters";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellLastEditedAtProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellLastEditedAt({ value }: CellLastEditedAtProps) {
const formatted = formatTimestamp(typeof value === "string" ? value : null);
if (!formatted) {
return <span className={cellClasses.emptyValue} />;
}
return <span className={cellClasses.dateValue}>{formatted}</span>;
}
@@ -0,0 +1,41 @@
import { Group, Tooltip } from "@mantine/core";
import { IBaseProperty } from "@/ee/base/types/base.types";
import { useReferenceStore } from "@/ee/base/reference/reference-store";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellLastEditedByProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellLastEditedBy({ value, property }: CellLastEditedByProps) {
const userId = typeof value === "string" ? value : null;
const store = useReferenceStore(property.pageId);
const user = userId ? store.users[userId] ?? null : null;
if (!userId) {
return <span className={cellClasses.emptyValue} />;
}
const name = user?.name ?? userId.substring(0, 8);
return (
<Group gap={6} wrap="nowrap" style={{ overflow: "hidden" }}>
<CustomAvatar
avatarUrl={user?.avatarUrl ?? ""}
name={name}
size={20}
radius="xl"
/>
<Tooltip label={name} withinPortal openDelay={400} disabled={!name}>
<span className={cellClasses.lastEditedByName}>{name}</span>
</Tooltip>
</Group>
);
}
@@ -0,0 +1,151 @@
import { useEffect, useRef, useState } from "react";
import { Popover, Textarea, Group, CloseButton, Tooltip } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { IBaseProperty } from "@/ee/base/types/base.types";
import { formatLongTextPreview } from "@/ee/base/formatters/cell-formatters";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellLongTextProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
onTabNavigate?: (shiftKey: boolean) => void;
};
const toText = (value: unknown) => (typeof value === "string" ? value : "");
const normalize = (s: string) => {
const trimmed = s.trim();
return trimmed.length ? trimmed : null;
};
export function CellLongText({
value,
isEditing,
onCommit,
onValueChange,
onCancel,
onTabNavigate,
}: CellLongTextProps) {
const [draft, setDraft] = useState(() => toText(value));
const cancelledRef = useRef(false);
const committedRef = useRef(false);
const wasEditingRef = useRef(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Seed draft and focus on the false->true editing transition only; ignore
// value changes mid-edit so the user's typing is not clobbered.
useEffect(() => {
if (isEditing && !wasEditingRef.current) {
cancelledRef.current = false;
committedRef.current = false;
setDraft(toText(value));
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
});
}
wasEditingRef.current = isEditing;
}, [isEditing, value]);
// Autosave after a typing pause; commit/cancel clear the pending fire so
// a closed editor can never write a stale or discarded draft.
const debouncedAutosave = useDebouncedCallback(() => {
onValueChange(normalize(draft));
}, 10_000);
const commit = () => {
if (committedRef.current) return;
committedRef.current = true;
debouncedAutosave.cancel();
onCommit(normalize(draft));
};
const cancel = () => {
cancelledRef.current = true;
debouncedAutosave.cancel();
onCancel();
};
const preview = formatLongTextPreview(toText(value));
return (
<Popover
opened={isEditing}
onChange={(opened) => {
if (opened) return;
// Programmatic close after cancel must not re-commit.
if (cancelledRef.current) {
cancelledRef.current = false;
return;
}
commit();
}}
position="bottom-start"
width={320}
shadow="md"
withinPortal
closeOnClickOutside
closeOnEscape={false}
trapFocus
>
<Popover.Target>
<div className={cellClasses.popoverTargetFlex}>
{preview ? (
<Tooltip label={toText(value)} multiline withinPortal openDelay={400} maw={420}>
<span className={cellClasses.longTextPreview}>{preview}</span>
</Tooltip>
) : (
<span className={cellClasses.emptyValue} />
)}
</div>
</Popover.Target>
<Popover.Dropdown
p={4}
onClick={(e) => e.stopPropagation()}
className={cellClasses.longTextDropdown}
>
{isEditing && (
<>
<Group justify="flex-end" mb={2}>
<CloseButton size="sm" onClick={commit} aria-label="Close" />
</Group>
<Textarea
ref={textareaRef}
data-autofocus
autosize
minRows={3}
maxRows={12}
maxLength={25000}
variant="unstyled"
value={draft}
onChange={(e) => {
setDraft(e.currentTarget.value);
debouncedAutosave();
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Tab") {
e.preventDefault();
commit();
onTabNavigate?.(e.shiftKey);
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
commit();
}
}}
styles={{ input: { padding: 4 } }}
/>
</>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,289 @@
import {
useState,
useRef,
useEffect,
useCallback,
useMemo,
} from "react";
import { Popover, TextInput } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo",
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
];
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
type CellMultiSelectProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
};
export function CellMultiSelect({
value,
property,
isEditing,
onValueChange,
onCancel,
}: CellMultiSelectProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedIds = Array.isArray(value) ? (value as string[]) : [];
const selectedSet = new Set(selectedIds);
const selectedChoices = choices.filter((c) => selectedSet.has(c.id));
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const filteredChoices = (
search
? choices.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
: choices
).filter((c) => !selectedSet.has(c.id));
const handleToggle = useCallback(
(choice: Choice) => {
const newIds = selectedSet.has(choice.id)
? selectedIds.filter((id) => id !== choice.id)
: [...selectedIds, choice.id];
onValueChange(newIds);
},
[selectedIds, selectedSet, onValueChange],
);
const updatePropertyMutation = useUpdatePropertyMutation();
const trimmedSearch = search.trim();
const hasExactMatch = useMemo(
() =>
trimmedSearch.length > 0 &&
choices.some((c) => c.name.toLowerCase() === trimmedSearch.toLowerCase()),
[choices, trimmedSearch],
);
const showAddOption = trimmedSearch.length > 0 && !hasExactMatch;
const addOptionColor = useMemo(
() => CHOICE_COLORS[choices.length % CHOICE_COLORS.length],
[choices.length],
);
const navItems = useMemo<NavItem[]>(
() => [
...filteredChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[filteredChoices, showAddOption],
);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(navItems.length, [search, isEditing, showAddOption]);
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: generateBaseChoiceId(),
name: trimmedSearch,
color: addOptionColor,
};
const newChoices = [...choices, newChoice];
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
typeOptions: {
...typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
},
});
onValueChange([...selectedIds, newChoice.id]);
setSearch("");
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, selectedIds, onValueChange]);
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 < navItems.length) {
e.preventDefault();
const item = navItems[activeIndex];
if (item.kind === "choice") handleToggle(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onCancel, handleNavKey, activeIndex, navItems, handleToggle, handleAddOption, showAddOption],
);
if (isEditing) {
const addOptionIdx = filteredChoices.length;
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
<BadgeList choices={selectedChoices} />
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
{selectedChoices.length > 0 && (
<div className={cellClasses.personTagArea}>
{selectedChoices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
<button
type="button"
className={`${cellClasses.personTagRemove} ${cellClasses.badgeRemoveBtn}`}
onClick={(e) => {
e.stopPropagation();
handleToggle(choice);
}}
>
<IconX size={10} />
</button>
</span>
))}
</div>
)}
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
data-autofocus
/>
<div className={cellClasses.selectDropdown}>
{filteredChoices.map((choice, idx) => (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleToggle(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
))}
{showAddOption && (
<div
ref={setOptionRef(addOptionIdx)}
className={clsx(
cellClasses.addOptionRow,
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(addOptionIdx)}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span
className={cellClasses.badge}
style={choiceColor(addOptionColor)}
>
{trimmedSearch}
</span>
</div>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (selectedChoices.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
return (
<BadgeList
choices={selectedChoices}
tooltipLabel={selectedChoices.map((c) => c.name).join(", ")}
/>
);
}
function BadgeList({
choices,
tooltipLabel,
}: {
choices: Choice[];
tooltipLabel?: string;
}) {
const chips = choices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
));
return (
<BadgeOverflowList
chips={chips}
measureKey={choices.map((c) => `${c.id}:${c.name}`).join("|")}
tooltipLabel={tooltipLabel}
/>
);
}
@@ -0,0 +1,158 @@
import {
IBaseProperty,
NumberTypeOptions,
} from "@/ee/base/types/base.types";
import { formatCurrency } from "@/ee/base/constants/currencies";
import { snapNumber } from "@docmost/base-formula/client";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellNumberProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const SEPARATOR_CHARS: Record<string, { group: string; decimal: string }> = {
comma_period: { group: ",", decimal: "." },
period_comma: { group: ".", decimal: "," },
space_comma: { group: " ", decimal: "," },
space_period: { group: " ", decimal: "." },
};
function separatorChars(style: string): { group: string; decimal: string } {
if (style === "local") {
const parts = new Intl.NumberFormat().formatToParts(11111.1);
return {
group: parts.find((p) => p.type === "group")?.value ?? ",",
decimal: parts.find((p) => p.type === "decimal")?.value ?? ".",
};
}
return SEPARATOR_CHARS[style] ?? { group: ",", decimal: "." };
}
function formatPlain(
value: number,
precision: number | undefined,
style: string,
): string {
const fixed = precision == null ? String(value) : value.toFixed(precision);
if (style === "none") return fixed;
const { group, decimal } = separatorChars(style);
const neg = fixed[0] === "-";
const abs = neg ? fixed.slice(1) : fixed;
const dot = abs.indexOf(".");
const intPart = dot === -1 ? abs : abs.slice(0, dot);
const fracPart = dot === -1 ? "" : abs.slice(dot + 1);
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, group);
const out = fracPart ? `${grouped}${decimal}${fracPart}` : grouped;
return neg ? `-${out}` : out;
}
export function formatNumber(
val: number | null | undefined,
options: NumberTypeOptions | undefined,
): string {
if (val == null) return "";
const precision = options?.precision;
const format = options?.format ?? "plain";
const style = options?.separators ?? "none";
const v = precision == null ? snapNumber(val) : val;
switch (format) {
case "currency":
return formatCurrency(v, options?.currencyCode, precision);
case "percent":
return `${formatPlain(v, precision, style)}%`;
case "progress":
return `${Math.min(100, Math.max(0, v)).toFixed(0)}%`;
default:
return formatPlain(v, precision, style);
}
}
const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : "";
export function sanitizeNumberInput(text: string): string {
return text.replace(/[^0-9.-]/g, "");
}
export function parseNumberDraft(draft: string): number | null {
const cleaned = sanitizeNumberInput(draft);
if (cleaned === "" || cleaned === "-") return null;
const parsed = Number(cleaned);
return isNaN(parsed) ? null : parsed;
}
export function CellNumber({
value,
property,
rowId,
isEditing,
onCommit,
onCancel,
}: CellNumberProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse: parseNumberDraft,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
inputMode="decimal"
className={`${cellClasses.cellInput} ${cellClasses.numberInput}`}
value={draft}
onChange={(e) => {
const v = e.target.value;
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
setDraft(v);
}
}}
onPaste={(e) => {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? draft.length;
const end = el.selectionEnd ?? draft.length;
setDraft(
draft.slice(0, start) +
sanitizeNumberInput(e.clipboardData.getData("text")) +
draft.slice(end),
);
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const numValue = typeof value === "number" ? value : null;
if (numValue == null) {
return <span className={cellClasses.emptyValue} />;
}
return (
<AutoTooltipText
className={cellClasses.numberValue}
fz="sm"
tooltipProps={{ withinPortal: true }}
>
{formatNumber(numValue, typeOptions)}
</AutoTooltipText>
);
}
@@ -0,0 +1,339 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, ActionIcon, Text, Tooltip } 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 { useTranslation } from "react-i18next";
import clsx from "clsx";
import { IBaseProperty } from "@/ee/base/types/base.types";
import { useResolvePage } from "@/ee/base/reference/reference-store";
import { useBaseQuery } from "@/ee/base/queries/base-query";
import { searchSuggestions } from "@/features/search/services/search-service";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
import { usePageQuery } from "@/features/page/queries/page-query";
import { extractPageSlugId } from "@/lib";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import cellClasses from "@/ee/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;
}
function parsePastedPageSlugId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
let path = trimmed;
if (/^https?:\/\//i.test(trimmed)) {
try {
path = new URL(trimmed).pathname;
} catch {
return null;
}
}
const match = path.match(/\/p\/([^/?#]+)/);
if (!match) return null;
return extractPageSlugId(match[1]) ?? null;
}
export function CellPage({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellPageProps) {
const pageId = parsePageId(value);
const { data: base } = useBaseQuery(property.pageId);
const resolvedPage = useResolvePage(property.pageId, pageId);
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) {
// placeholder to avoid "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 { t } = useTranslation();
const title = getPageTitle(page.title, undefined, t);
const spaceSlug = page.space?.slug ?? "";
const url = buildPageUrl(spaceSlug, page.slugId, title);
return (
<Tooltip label={title} withinPortal openDelay={400} disabled={!title}>
<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>
</Tooltip>
);
}
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 { t } = useTranslation();
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
requestAnimationFrame(() => searchRef.current?.focus());
}, []);
const trimmed = debouncedSearch.trim();
const pastedSlugId = useMemo(
() => parsePastedPageSlugId(debouncedSearch),
[debouncedSearch],
);
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[];
},
enabled: !pastedSlugId,
staleTime: 15_000,
});
// Once the pasted link resolves via slugId lookup, commit and close.
const { data: linkedPage, isFetching: resolvingLink } = usePageQuery(
pastedSlugId ? { pageId: pastedSlugId } : {},
);
const linkedRef = useRef(false);
useEffect(() => {
if (!pastedSlugId) {
linkedRef.current = false;
return;
}
if (linkedPage && !linkedRef.current) {
linkedRef.current = true;
onCommit(linkedPage.id);
}
}, [pastedSlugId, linkedPage, onCommit]);
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
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={320}
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
{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}
color="var(--mantine-color-dimmed)"
/>
)}
<span className={cellClasses.personTagName}>
{getPageTitle(resolvedPage.title, undefined, t)}
</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}
data-autofocus
/>
</div>
<div className={cellClasses.personDropdownDivider} />
<div className={cellClasses.selectDropdown}>
{pastedSlugId ? (
<div className={cellClasses.personDropdownHint}>
{resolvingLink || linkedPage ? "Linking page…" : "Page not found"}
</div>
) : (
<>
{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)}
onClick={() => handleSelect(page.id)}
>
{page.icon ? (
<span>{page.icon}</span>
) : (
<IconFileDescription
size={14}
color="var(--mantine-color-dimmed)"
/>
)}
<div className={cellClasses.pageOptionText}>
<span className={cellClasses.personOptionName}>
{getPageTitle(page.title, undefined, t)}
</span>
{page.space?.name && (
<Text size="xs" c="dimmed" truncate>
{page.space.name}
</Text>
)}
</div>
</div>
);
})}
</>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,247 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { Popover } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import clsx from "clsx";
import {
IBaseProperty,
PersonTypeOptions,
} from "@/ee/base/types/base.types";
import {
useReferenceStore,
useHydrateUsers,
} from "@/ee/base/reference/reference-store";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { PersonReadList } from "@/ee/base/components/cells/person-read-list";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import { usePersonSearch } from "@/ee/base/hooks/use-person-search";
type CellPersonProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
};
export function CellPerson({
value,
property,
isEditing,
onCommit,
onValueChange,
onCancel,
}: CellPersonProps) {
const allowMultiple =
(property.typeOptions as PersonTypeOptions)?.allowMultiple === true;
const personIds = Array.isArray(value)
? (value as string[])
: typeof value === "string"
? [value]
: [];
const selectedSet = new Set(personIds);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const store = useReferenceStore(property.pageId);
const hydrateUsers = useHydrateUsers(property.pageId);
const suggestions = usePersonSearch(search, isEditing);
// In multi mode omit already-selected from the list (they appear as tags above).
// Single mode keeps the selected row visible so it can be deselected.
const filteredMembers = allowMultiple
? suggestions.filter((m) => !selectedSet.has(m.id))
: suggestions;
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(filteredMembers.length, [search, isEditing]);
const handleSelect = useCallback(
(memberId: string) => {
const picked = suggestions.find((s) => s.id === memberId);
if (picked)
hydrateUsers([
{ id: picked.id, name: picked.name, avatarUrl: picked.avatarUrl },
]);
if (allowMultiple) {
if (personIds.includes(memberId)) {
const newIds = personIds.filter((id) => id !== memberId);
onValueChange(newIds.length > 0 ? newIds : null);
} else {
onValueChange([...personIds, memberId]);
}
} else {
if (personIds.includes(memberId)) {
onCommit(null);
} else {
onCommit(memberId);
}
}
},
[suggestions, hydrateUsers, allowMultiple, personIds, onCommit, onValueChange],
);
const handleRemove = useCallback(
(memberId: string) => {
if (allowMultiple) {
const newIds = personIds.filter((id) => id !== memberId);
onValueChange(newIds.length > 0 ? newIds : null);
} else {
onCommit(null);
}
},
[allowMultiple, personIds, onCommit, onValueChange],
);
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 >= filteredMembers.length) return;
e.preventDefault();
handleSelect(filteredMembers[activeIndex].id);
return;
}
if (e.key === "Backspace" && search === "" && personIds.length > 0) {
e.preventDefault();
handleRemove(personIds[personIds.length - 1]);
}
},
[onCancel, handleNavKey, activeIndex, filteredMembers, handleSelect, search, personIds, handleRemove],
);
if (isEditing) {
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={300}
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
<PersonReadList personIds={personIds} users={store.users} />
</div>
</Popover.Target>
<Popover.Dropdown p={0}>
<div className={cellClasses.personTagArea}>
{personIds.map((id) => {
const member = store.users[id];
const name = member?.name ?? id.substring(0, 8);
return (
<span key={id} className={cellClasses.personTag}>
<CustomAvatar
avatarUrl={member?.avatarUrl ?? ""}
name={name}
size={18}
radius="xl"
/>
<span className={cellClasses.personTagName}>{name}</span>
<button
type="button"
className={cellClasses.personTagRemove}
onClick={(e) => {
e.stopPropagation();
handleRemove(id);
}}
>
<IconX size={10} />
</button>
</span>
);
})}
<input
ref={searchRef}
className={cellClasses.personTagInput}
placeholder={personIds.length === 0 ? "Search for a person..." : ""}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
data-autofocus
/>
</div>
<div className={cellClasses.personDropdownDivider} />
{allowMultiple && (
<div className={cellClasses.personDropdownHint}>
Select as many as you like
</div>
)}
<div className={cellClasses.selectDropdown}>
{filteredMembers.map((member, idx) => {
const isSelected = selectedSet.has(member.id);
return (
<div
key={member.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleSelect(member.id)}
>
<CustomAvatar
avatarUrl={member.avatarUrl ?? ""}
name={member.name ?? ""}
size={24}
radius="xl"
/>
<div className={cellClasses.personOptionText}>
<span className={cellClasses.personOptionName}>
{member.name ?? ""}
</span>
{member.email && (
<span className={cellClasses.personOptionEmail}>
{member.email}
</span>
)}
</div>
</div>
);
})}
{filteredMembers.length === 0 && (
<div className={cellClasses.personDropdownHint}>
No members found
</div>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (personIds.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
return <PersonReadList personIds={personIds} users={store.users} />;
}
@@ -0,0 +1,239 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo",
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
];
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
type CellSelectProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellSelect({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellSelectProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedId = typeof value === "string" ? value : null;
const selectedChoice = choices.find((c) => c.id === selectedId);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const filteredChoices = search
? choices.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()),
)
: choices;
const handleSelect = useCallback(
(choice: Choice) => {
onCommit(choice.id === selectedId ? null : choice.id);
},
[selectedId, onCommit],
);
const updatePropertyMutation = useUpdatePropertyMutation();
const trimmedSearch = search.trim();
const hasExactMatch = useMemo(
() =>
trimmedSearch.length > 0 &&
choices.some((c) => c.name.toLowerCase() === trimmedSearch.toLowerCase()),
[choices, trimmedSearch],
);
const showAddOption = trimmedSearch.length > 0 && !hasExactMatch;
const addOptionColor = useMemo(
() => CHOICE_COLORS[choices.length % CHOICE_COLORS.length],
[choices.length],
);
const navItems = useMemo<NavItem[]>(
() => [
...filteredChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[filteredChoices, showAddOption],
);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(navItems.length, [search, isEditing, showAddOption]);
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: generateBaseChoiceId(),
name: trimmedSearch,
color: addOptionColor,
};
const newChoices = [...choices, newChoice];
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
typeOptions: {
...typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
},
});
onCommit(newChoice.id);
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, 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 < navItems.length) {
e.preventDefault();
const item = navItems[activeIndex];
if (item.kind === "choice") handleSelect(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onCancel, handleNavKey, activeIndex, navItems, handleSelect, handleAddOption, showAddOption],
);
if (isEditing) {
const addOptionIdx = filteredChoices.length;
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
{selectedChoice ? (
<span
className={cellClasses.badge}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
) : (
<span className={cellClasses.emptyValue} />
)}
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
/>
<div className={cellClasses.selectDropdown}>
{filteredChoices.map((choice, idx) => {
const isSelected = choice.id === selectedId;
return (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleSelect(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
{showAddOption && (
<div
ref={setOptionRef(addOptionIdx)}
className={clsx(
cellClasses.addOptionRow,
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(addOptionIdx)}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span
className={cellClasses.badge}
style={choiceColor(addOptionColor)}
>
{trimmedSearch}
</span>
</div>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (!selectedChoice) {
return <span className={cellClasses.emptyValue} />;
}
return (
<ChoiceBadge
name={selectedChoice.name}
style={choiceColor(selectedChoice.color)}
/>
);
}
@@ -0,0 +1,202 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
import cellClasses from "@/ee/base/styles/cells.module.css";
import clsx from "clsx";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
type CellStatusProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
type CategoryGroup = {
label: string;
choices: Choice[];
};
const categoryLabels: Record<string, string> = {
todo: "To Do",
inProgress: "In Progress",
complete: "Complete",
};
export function CellStatus({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellStatusProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedId = typeof value === "string" ? value : null;
const selectedChoice = choices.find((c) => c.id === selectedId);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const groups = useMemo(() => {
const filtered = search
? choices.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()),
)
: choices;
const grouped: Record<string, Choice[]> = {};
for (const choice of filtered) {
const cat = choice.category ?? "todo";
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(choice);
}
const result: CategoryGroup[] = [];
for (const key of ["todo", "inProgress", "complete"]) {
if (grouped[key]?.length) {
result.push({ label: categoryLabels[key] ?? key, choices: grouped[key] });
}
}
return result;
}, [choices, search]);
const flatChoices = useMemo(
() => groups.flatMap((g) => g.choices),
[groups],
);
const choiceIdxMap = useMemo(() => {
const m = new Map<string, number>();
flatChoices.forEach((c, i) => m.set(c.id, i));
return m;
}, [flatChoices]);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(flatChoices.length, [search, isEditing]);
const handleSelect = useCallback(
(choice: Choice) => {
onCommit(choice.id === selectedId ? null : choice.id);
},
[selectedId, 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 >= flatChoices.length) return;
e.preventDefault();
handleSelect(flatChoices[activeIndex]);
}
},
[onCancel, handleNavKey, activeIndex, flatChoices, handleSelect],
);
if (isEditing) {
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
{selectedChoice ? (
<span
className={cellClasses.badge}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
) : (
<span className={cellClasses.emptyValue} />
)}
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
/>
<div className={cellClasses.selectDropdown}>
{groups.map((group) => (
<div key={group.label}>
<div className={cellClasses.selectCategoryLabel}>
{group.label}
</div>
{group.choices.map((choice) => {
const idx = choiceIdxMap.get(choice.id) ?? -1;
const isSelected = choice.id === selectedId;
return (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleSelect(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
</div>
))}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (!selectedChoice) {
return <span className={cellClasses.emptyValue} />;
}
return (
<ChoiceBadge
name={selectedChoice.name}
style={choiceColor(selectedChoice.color)}
/>
);
}
@@ -0,0 +1,60 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
import cellClasses from "@/ee/base/styles/cells.module.css";
import gridClasses from "@/ee/base/styles/grid.module.css";
type CellTextProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft;
export function CellText({ value, property, rowId, isEditing, onCommit, onCancel }: CellTextProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
className={cellClasses.cellInput}
value={draft}
maxLength={1000}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const displayValue = toDraft(value);
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<AutoTooltipText
className={gridClasses.cellContent}
fz="sm"
tooltipProps={{ withinPortal: true }}
>
{displayValue}
</AutoTooltipText>
);
}
@@ -0,0 +1,70 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { sanitizeUrl } from "@docmost/editor-ext";
import { Tooltip } from "@mantine/core";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellUrlProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft || null;
export function CellUrl({ value, property, rowId, isEditing, onCommit, onCancel }: CellUrlProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
<input
ref={inputRef}
type="url"
className={cellClasses.cellInput}
value={draft}
placeholder="https://..."
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const displayValue = toDraft(value);
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
const safeHref = sanitizeUrl(displayValue);
if (!safeHref) {
return <span>{displayValue}</span>;
}
return (
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
<a
className={cellClasses.urlLink}
href={safeHref}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
</Tooltip>
);
}
@@ -0,0 +1,30 @@
function isEmpty(v: unknown): boolean {
return v === null || v === undefined || v === "";
}
/**
* Deep equality for cell values. Treats null/undefined/"" as equivalent
* "empty", compares arrays by ordered content, objects by serialized form.
* Note: an empty array [] is a real value, distinct from "empty".
*/
export function cellValuesEqual(a: unknown, b: unknown): boolean {
const aArr = Array.isArray(a);
const bArr = Array.isArray(b);
if (!aArr && !bArr) {
if (isEmpty(a) && isEmpty(b)) return true;
if (isEmpty(a) !== isEmpty(b)) return false;
}
if (aArr || bArr) {
if (!aArr || !bArr) return false;
if (a.length !== b.length) return false;
return a.every((x, i) => cellValuesEqual(x, b[i]));
}
if (typeof a === "object" && typeof b === "object" && a && b) {
return JSON.stringify(a) === JSON.stringify(b);
}
return a === b;
}
@@ -0,0 +1,29 @@
import { CSSProperties, useRef, useState } from "react";
import { Tooltip } from "@mantine/core";
import cellClasses from "@/ee/base/styles/cells.module.css";
type ChoiceBadgeProps = {
name: string;
style: CSSProperties;
};
export function ChoiceBadge({ name, style }: ChoiceBadgeProps) {
const ref = useRef<HTMLSpanElement>(null);
const [truncated, setTruncated] = useState(false);
return (
<Tooltip label={name} withinPortal openDelay={400} disabled={!truncated}>
<span
ref={ref}
className={cellClasses.badge}
style={style}
onMouseEnter={() => {
const el = ref.current;
if (el) setTruncated(el.scrollWidth > el.clientWidth);
}}
>
{name}
</span>
</Tooltip>
);
}
@@ -0,0 +1,25 @@
import { CSSProperties } from "react";
const colorMap: Record<string, { bg: string; bgDark: string; text: string; textDark: string }> = {
gray: { bg: "#f1f3f5", bgDark: "#373a40", text: "#495057", textDark: "#ced4da" },
red: { bg: "#ffe3e3", bgDark: "#4a1a1a", text: "#bf2020", textDark: "#ffa8a8" },
pink: { bg: "#ffdeeb", bgDark: "#4a1a2e", text: "#a61e4d", textDark: "#faa2c1" },
grape: { bg: "#f3d9fa", bgDark: "#3b1a4a", text: "#862e9c", textDark: "#e599f7" },
violet: { bg: "#e5dbff", bgDark: "#2b1a4a", text: "#5f3dc4", textDark: "#b197fc" },
indigo: { bg: "#dbe4ff", bgDark: "#1a2b4a", text: "#364fc7", textDark: "#91a7ff" },
blue: { bg: "#d0ebff", bgDark: "#1a2e4a", text: "#1864ab", textDark: "#74c0fc" },
cyan: { bg: "#c5f6fa", bgDark: "#1a3a3a", text: "#0b7285", textDark: "#66d9e8" },
teal: { bg: "#c3fae8", bgDark: "#1a3a2e", text: "#066649", textDark: "#63e6be" },
green: { bg: "#d3f9d8", bgDark: "#1a3a1a", text: "#1b5e20", textDark: "#69db7c" },
lime: { bg: "#e9fac8", bgDark: "#2e3a1a", text: "#44700a", textDark: "#a9e34b" },
yellow: { bg: "#fff3bf", bgDark: "#3a351a", text: "#8a5200", textDark: "#ffd43b" },
orange: { bg: "#ffe8cc", bgDark: "#3a2a1a", text: "#a63508", textDark: "#ffa94d" },
};
export function choiceColor(color: string): CSSProperties {
const c = colorMap[color] ?? colorMap.gray;
return {
backgroundColor: `light-dark(${c.bg}, ${c.bgDark})`,
color: `light-dark(${c.text}, ${c.textDark})`,
};
}
@@ -0,0 +1,258 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { TextInput } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import cellClasses from "@/ee/base/styles/cells.module.css";
const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo",
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
];
const STATUS_CATEGORY_LABELS: Record<string, string> = {
todo: "To Do",
inProgress: "In Progress",
complete: "Complete",
};
const STATUS_CATEGORY_ORDER = ["todo", "inProgress", "complete"];
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
type ChoiceGroup = { label: string | null; choices: Choice[] };
type ChoicePickerProps = {
property: IBaseProperty;
selectedIds: string[];
/** Multi keeps the picker open, hides picked options from the list and
* shows them as removable tags instead. */
multiple?: boolean;
/** Group options under status category headings. */
grouped?: boolean;
/** Offer "Add option: <search>" when the search has no exact match. */
allowCreate?: boolean;
onToggle: (choice: Choice) => void;
onEscape: () => void;
};
/** Searchable choice list shared by select-like editors (modal fields; the
* grid cells render the same UI and can migrate here). */
export function ChoicePicker({
property,
selectedIds,
multiple = false,
grouped = false,
allowCreate = false,
onToggle,
onEscape,
}: ChoicePickerProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const selectedChoices = choices.filter((c) => selectedSet.has(c.id));
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
requestAnimationFrame(() => searchRef.current?.focus());
}, []);
const groups = useMemo<ChoiceGroup[]>(() => {
const filtered = (
search
? choices.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()),
)
: choices
).filter((c) => !multiple || !selectedSet.has(c.id));
if (!grouped) return [{ label: null, choices: filtered }];
const byCategory: Record<string, Choice[]> = {};
for (const choice of filtered) {
const cat = choice.category ?? "todo";
(byCategory[cat] ??= []).push(choice);
}
return STATUS_CATEGORY_ORDER.filter((key) => byCategory[key]?.length).map(
(key) => ({ label: STATUS_CATEGORY_LABELS[key] ?? key, choices: byCategory[key] }),
);
}, [choices, search, grouped, multiple, selectedSet]);
const flatChoices = useMemo(() => groups.flatMap((g) => g.choices), [groups]);
const choiceIdxMap = useMemo(() => {
const m = new Map<string, number>();
flatChoices.forEach((c, i) => m.set(c.id, i));
return m;
}, [flatChoices]);
const updatePropertyMutation = useUpdatePropertyMutation();
const trimmedSearch = search.trim();
const hasExactMatch = useMemo(
() =>
trimmedSearch.length > 0 &&
choices.some((c) => c.name.toLowerCase() === trimmedSearch.toLowerCase()),
[choices, trimmedSearch],
);
const showAddOption = allowCreate && trimmedSearch.length > 0 && !hasExactMatch;
const addOptionColor = useMemo(
() => CHOICE_COLORS[choices.length % CHOICE_COLORS.length],
[choices.length],
);
const navItems = useMemo<NavItem[]>(
() => [
...flatChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[flatChoices, showAddOption],
);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(navItems.length, [search, showAddOption]);
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: generateBaseChoiceId(),
name: trimmedSearch,
color: addOptionColor,
};
const newChoices = [...choices, newChoice];
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
typeOptions: {
...typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
},
});
onToggle(newChoice);
setSearch("");
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, onToggle]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onEscape();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex >= 0 && activeIndex < navItems.length) {
e.preventDefault();
const item = navItems[activeIndex];
if (item.kind === "choice") onToggle(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onEscape, handleNavKey, activeIndex, navItems, onToggle, handleAddOption, showAddOption],
);
const addOptionIdx = flatChoices.length;
return (
<>
{multiple && selectedChoices.length > 0 && (
<div className={cellClasses.personTagArea}>
{selectedChoices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
<button
type="button"
className={`${cellClasses.personTagRemove} ${cellClasses.badgeRemoveBtn}`}
onClick={(e) => {
e.stopPropagation();
onToggle(choice);
}}
>
<IconX size={10} />
</button>
</span>
))}
</div>
)}
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
data-autofocus
/>
<div className={cellClasses.selectDropdown}>
{groups.map((group) => (
<div key={group.label ?? "all"}>
{group.label && (
<div className={cellClasses.selectCategoryLabel}>{group.label}</div>
)}
{group.choices.map((choice) => {
const idx = choiceIdxMap.get(choice.id) ?? -1;
const isSelected = !multiple && selectedSet.has(choice.id);
return (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => onToggle(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
</div>
))}
{showAddOption && (
<div
ref={setOptionRef(addOptionIdx)}
className={clsx(
cellClasses.addOptionRow,
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(addOptionIdx)}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span className={cellClasses.badge} style={choiceColor(addOptionColor)}>
{trimmedSearch}
</span>
</div>
)}
</div>
</>
);
}
@@ -0,0 +1,40 @@
import clsx from "clsx";
import { UserRef } from "@/ee/base/types/base.types";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
import cellClasses from "@/ee/base/styles/cells.module.css";
type PersonReadListProps = {
personIds: string[];
users: Record<string, UserRef>;
};
export function PersonReadList({ personIds, users }: PersonReadListProps) {
const entries = personIds.map((id) => ({
id,
name: users[id]?.name ?? id.substring(0, 8),
avatarUrl: users[id]?.avatarUrl ?? "",
}));
const chips = entries.map((entry) => (
<span
key={entry.id}
className={clsx(cellClasses.badge, cellClasses.personChip)}
>
<CustomAvatar
avatarUrl={entry.avatarUrl}
name={entry.name}
size={16}
radius="xl"
style={{ flexShrink: 0 }}
/>
<span className={cellClasses.personChipName}>{entry.name}</span>
</span>
));
return (
<BadgeOverflowList
chips={chips}
measureKey={entries.map((e) => `${e.id}:${e.name}`).join("|")}
tooltipLabel={entries.map((e) => e.name).join(", ")}
/>
);
}