feat(ee): bases

Table and kanban UI, formula engine package, and the base-embed editor extension
This commit is contained in:
Philipinho
2026-06-14 01:29:06 +01:00
parent d86d51c27e
commit 4e5bff6d55
233 changed files with 22278 additions and 141 deletions
@@ -0,0 +1,142 @@
import { forwardRef } from "react";
import { Checkbox } from "@mantine/core";
import { IconLock } from "@tabler/icons-react";
import clsx from "clsx";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { FieldText } from "./field-text";
import { FieldLongText } from "./field-long-text";
import { FieldNumber } from "./field-number";
import { FieldDate } from "./field-date";
import { FieldChoice } from "./field-choice";
import { FieldCellAdapter } from "./field-cell-adapter";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
export type FieldProps = {
property: IBaseProperty;
value: unknown;
rowId: string;
readOnly: boolean;
onChange: (value: unknown) => void;
};
type FieldShellProps = {
/** Visual + cursor treatment: text caret, pointer (opens a picker), or none. */
cursor?: "text" | "pointer" | "default";
/** Popover open — keeps the focus ring while focus is in the portal. */
active?: boolean;
locked?: boolean;
alignTop?: boolean;
children?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>;
// forwardRef is load-bearing: Popover.Target anchors its dropdown through a
// ref injected into this element; without it the picker renders at (0,0).
export const FieldShell = forwardRef<HTMLDivElement, FieldShellProps>(
function FieldShell(
{ cursor = "default", active, locked, alignTop, className, children, ...rest },
ref,
) {
return (
<div
ref={ref}
className={clsx(
classes.fieldShell,
cursor === "text" && classes.fieldShellText,
cursor === "pointer" && classes.fieldShellPointer,
active && classes.fieldShellActive,
locked && classes.fieldShellLocked,
alignTop && classes.fieldShellTop,
className,
)}
{...rest}
>
{locked && <IconLock size={13} className={classes.fieldLockIcon} />}
{children}
</div>
);
},
);
function FieldCheckbox({ value, readOnly, onChange }: FieldProps) {
const checked = value === true;
return (
<FieldShell>
<Checkbox
size="sm"
checked={checked}
disabled={readOnly}
onChange={() => onChange(!checked)}
/>
</FieldShell>
);
}
function FieldReadonlyCell({ property, value, rowId }: FieldProps) {
const CellComponent = getDescriptor(property.type)?.cellComponent;
return (
<FieldShell locked>
<div className={classes.fieldCellDisplay}>
{CellComponent && (
<CellComponent
value={value}
property={property}
rowId={rowId}
isEditing={false}
readOnly
onCommit={() => {}}
onValueChange={() => {}}
onCancel={() => {}}
/>
)}
</div>
</FieldShell>
);
}
type DetailFieldProps = {
property: IBaseProperty;
row: IBaseRow;
readOnly: boolean;
onUpdate: (propertyId: string, value: unknown) => void;
};
export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldProps) {
const descriptor = getDescriptor(property.type);
const value = descriptor?.systemAccessor
? descriptor.systemAccessor(row)
: (row.cells ?? {})[property.id];
const fieldProps: FieldProps = {
property,
value,
rowId: row.id,
readOnly,
onChange: (next: unknown) => onUpdate(property.id, next),
};
switch (property.type) {
case "text":
case "url":
case "email":
return <FieldText {...fieldProps} />;
case "longText":
return <FieldLongText {...fieldProps} />;
case "number":
return <FieldNumber {...fieldProps} />;
case "checkbox":
return <FieldCheckbox {...fieldProps} />;
case "date":
return <FieldDate {...fieldProps} />;
case "select":
case "status":
case "multiSelect":
return <FieldChoice {...fieldProps} />;
case "person":
case "file":
case "page":
return <FieldCellAdapter {...fieldProps} />;
default:
// createdAt, lastEditedAt, lastEditedBy, formula and future types.
return <FieldReadonlyCell {...fieldProps} />;
}
}
@@ -0,0 +1,80 @@
import { useCallback, useRef, useState } from "react";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
/** Person, file and page editors are popover pickers owned by their cell
* components; the shell supplies modal styling and click-anywhere
* activation while the cell keeps its picker behavior. */
export function FieldCellAdapter({
property,
value,
rowId,
readOnly,
onChange,
}: FieldProps) {
const [editing, setEditing] = useState(false);
// Whether the picker was open when the current gesture's mousedown fired.
const editingAtMouseDownRef = useRef(false);
const CellComponent = getDescriptor(property.type)?.cellComponent;
// Files stay openable read-only (download-only popover), matching the grid.
const canActivate = !readOnly || property.type === "file";
// Activate on click, not mousedown: opening on mousedown mounts the cell's
// picker mid-dispatch, and its document-level outside-mousedown listener
// then catches the same still-bubbling event and instantly closes it. By
// click time the mousedown has fully finished. The ref keeps toggle-close
// working: when the gesture started with the picker open, the picker's own
// outside-close already handled it and we must not reopen.
const handleMouseDown = useCallback(() => {
editingAtMouseDownRef.current = editing;
}, [editing]);
const handleClick = useCallback(() => {
if (!canActivate || editingAtMouseDownRef.current || editing) return;
setEditing(true);
}, [canActivate, editing]);
const handleCommit = useCallback(
(next: unknown) => {
setEditing(false);
onChange(next);
},
[onChange],
);
const handleCancel = useCallback(() => setEditing(false), []);
if (!CellComponent) return <FieldShell />;
return (
<FieldShell
cursor={canActivate ? "pointer" : "default"}
active={editing}
onMouseDown={handleMouseDown}
onClick={handleClick}
role={canActivate ? "button" : undefined}
tabIndex={canActivate ? 0 : undefined}
aria-label={property.name}
onKeyDown={(e) => {
if (canActivate && !editing && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setEditing(true);
}
}}
>
<div className={classes.fieldCellDisplay}>
<CellComponent
value={value}
property={property}
rowId={rowId}
isEditing={editing}
readOnly={readOnly}
onCommit={handleCommit}
onValueChange={onChange}
onCancel={handleCancel}
/>
</div>
</FieldShell>
);
}
@@ -0,0 +1,103 @@
import { useCallback, useState } from "react";
import { Popover } from "@mantine/core";
import { Choice, SelectTypeOptions } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { ChoicePicker } from "@/ee/base/components/cells/choice-picker";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
import cellClasses from "@/ee/base/styles/cells.module.css";
export function FieldChoice({ property, value, readOnly, onChange }: FieldProps) {
const [opened, setOpened] = useState(false);
const multiple = property.type === "multiSelect";
const choices =
(property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
const selectedIds = multiple
? Array.isArray(value)
? (value as string[])
: []
: typeof value === "string"
? [value]
: [];
const selectedChoices = choices.filter((c) => selectedIds.includes(c.id));
const handleToggle = useCallback(
(choice: Choice) => {
if (multiple) {
const next = selectedIds.includes(choice.id)
? selectedIds.filter((id) => id !== choice.id)
: [...selectedIds, choice.id];
onChange(next.length > 0 ? next : null);
} else {
onChange(choice.id === selectedIds[0] ? null : choice.id);
setOpened(false);
}
},
[multiple, selectedIds, onChange],
);
const chips = selectedChoices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
));
if (readOnly) {
return (
<FieldShell>
<div className={classes.fieldChips}>{chips}</div>
</FieldShell>
);
}
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-start"
width="target"
shadow="md"
withinPortal
trapFocus
closeOnClickOutside
closeOnEscape={false}
>
<Popover.Target>
<FieldShell
cursor="pointer"
active={opened}
role="button"
tabIndex={0}
aria-label={property.name}
onClick={() => setOpened((o) => !o)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpened((o) => !o);
}
}}
>
<div className={classes.fieldChips}>{chips}</div>
</FieldShell>
</Popover.Target>
<Popover.Dropdown p={4}>
{opened && (
<ChoicePicker
property={property}
selectedIds={selectedIds}
multiple={multiple}
grouped={property.type === "status"}
allowCreate={property.type !== "status"}
onToggle={handleToggle}
onEscape={() => setOpened(false)}
/>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,75 @@
import { useState } from "react";
import { Popover } from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import { DateTypeOptions } from "@/ee/base/types/base.types";
import { formatDateDisplay } from "@/ee/base/components/cells/cell-date";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
function toISODateString(dateStr: string | null): string | null {
if (!dateStr) return null;
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}`;
}
export function FieldDate({ property, value, readOnly, onChange }: FieldProps) {
const [opened, setOpened] = useState(false);
const typeOptions = property.typeOptions as DateTypeOptions | undefined;
const dateStr = typeof value === "string" ? value : null;
const display = formatDateDisplay(dateStr, typeOptions);
if (readOnly) {
return (
<FieldShell>
<span className={classes.fieldValueText}>{display}</span>
</FieldShell>
);
}
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-start"
width="auto"
shadow="md"
withinPortal
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<FieldShell
cursor="pointer"
active={opened}
role="button"
tabIndex={0}
aria-label={property.name}
onClick={() => setOpened((o) => !o)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpened((o) => !o);
}
}}
>
<span className={classes.fieldValueText}>{display}</span>
</FieldShell>
</Popover.Target>
<Popover.Dropdown p="xs">
<DatePicker
value={toISODateString(dateStr)}
onChange={(selected) => {
onChange(selected ? new Date(selected).toISOString() : null);
setOpened(false);
}}
size="sm"
/>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from "react";
import { Textarea } from "@mantine/core";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toText = (value: unknown) => (typeof value === "string" ? value : "");
const normalize = (s: string) => {
const trimmed = s.trim();
return trimmed.length ? trimmed : null;
};
export function FieldLongText({ property, value, readOnly, onChange }: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
// Esc sets this; blur() then runs commit synchronously with the stale
// draft, so the revert must be decided here, not via setDraft.
const cancelRef = useRef(false);
useEffect(() => {
if (!focused) setDraft(text);
}, [text, focused]);
const commit = () => {
setFocused(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
return;
}
if (normalize(draft) !== normalize(text)) onChange(normalize(draft));
};
if (readOnly) {
return (
<FieldShell alignTop>
<span className={classes.fieldValueTextMultiline}>{text}</span>
</FieldShell>
);
}
return (
<FieldShell cursor="text" alignTop>
<Textarea
autosize
minRows={3}
maxRows={16}
maxLength={25000}
variant="unstyled"
className={classes.fieldTextarea}
classNames={{ input: classes.fieldTextareaInput }}
value={draft}
onFocus={() => setFocused(true)}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
}
}}
aria-label={property.name}
/>
</FieldShell>
);
}
@@ -0,0 +1,79 @@
import { useEffect, useRef, useState } from "react";
import { NumberTypeOptions } from "@/ee/base/types/base.types";
import { formatNumber } from "@/ee/base/components/cells/cell-number";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : "";
const parse = (draft: string) => {
const parsed = draft === "" ? null : Number(draft);
return parsed != null && isNaN(parsed) ? null : parsed;
};
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const numValue = typeof value === "number" ? value : null;
const [draft, setDraft] = useState(toDraft(value));
const [focused, setFocused] = useState(false);
// Esc sets this; blur() then runs commit synchronously with the stale
// draft, so the revert must be decided here, not via setDraft.
const cancelRef = useRef(false);
useEffect(() => {
if (!focused) setDraft(toDraft(value));
}, [value, focused]);
const formatted = formatNumber(numValue, typeOptions);
if (readOnly) {
return (
<FieldShell>
<span className={classes.fieldValueText}>{formatted}</span>
</FieldShell>
);
}
const commit = () => {
setFocused(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(toDraft(value));
return;
}
if (parse(draft) !== numValue) onChange(parse(draft));
};
return (
<FieldShell cursor="text">
<input
type="text"
inputMode="decimal"
className={classes.fieldInput}
value={focused ? draft : formatted}
onFocus={() => {
setDraft(toDraft(value));
setFocused(true);
}}
onChange={(e) => {
const v = e.target.value;
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
setDraft(v);
}
}}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
} else if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
}
}}
aria-label={property.name}
/>
</FieldShell>
);
}
@@ -0,0 +1,89 @@
import { useEffect, useRef, useState } from "react";
import { IconExternalLink, IconMail } from "@tabler/icons-react";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toText = (value: unknown) => (typeof value === "string" ? value : "");
export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
// Esc sets this; blur() then runs commit synchronously with the stale
// draft, so the revert must be decided here, not via setDraft.
const cancelRef = useRef(false);
// Track remote/navigation updates while not typing.
useEffect(() => {
if (!focused) setDraft(text);
}, [text, focused]);
const commit = () => {
setFocused(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
return;
}
if (draft !== text) onChange(draft);
};
if (readOnly) {
return (
<FieldShell>
<span className={classes.fieldValueText}>{text}</span>
</FieldShell>
);
}
const linkHref =
!focused && text
? property.type === "email"
? text.includes("@")
? `mailto:${text}`
: null
: property.type === "url" && /^https?:\/\//i.test(text)
? text
: null
: null;
return (
<FieldShell cursor="text">
<input
type="text"
className={classes.fieldInput}
value={draft}
maxLength={1000}
onFocus={() => setFocused(true)}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
} else if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
}
}}
aria-label={property.name}
/>
{linkHref && (
<a
href={linkHref}
target={property.type === "url" ? "_blank" : undefined}
rel="noopener noreferrer"
className={classes.fieldTrailing}
onMouseDown={(e) => e.stopPropagation()}
aria-label={property.type === "email" ? `Email ${text}` : `Open ${text}`}
>
{property.type === "email" ? (
<IconMail size={14} />
) : (
<IconExternalLink size={14} />
)}
</a>
)}
</FieldShell>
);
}
@@ -0,0 +1,117 @@
import { useCallback, useEffect, useRef } from "react";
import clsx from "clsx";
import { Popover } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { PropertyMenuContent } from "@/ee/base/components/property/property-menu";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { DetailField } from "./fields/detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
type PropertyRowProps = {
property: IBaseProperty;
row: IBaseRow;
pageId: string;
menuOpened: boolean;
onMenuOpenChange: (opened: boolean) => void;
onMenuDirtyChange: (dirty: boolean) => void;
onUpdate: (propertyId: string, value: unknown) => void;
autoFocusValue?: boolean;
onAutoFocused?: () => void;
};
export function PropertyRow({
property,
row,
pageId,
menuOpened,
onMenuOpenChange,
onMenuDirtyChange,
onUpdate,
autoFocusValue,
onAutoFocused,
}: PropertyRowProps) {
const canEdit = useBaseEditable();
const rowRef = useRef<HTMLDivElement>(null);
const focusedRef = useRef(false);
useEffect(() => {
if (!autoFocusValue || focusedRef.current) return;
focusedRef.current = true;
const el = rowRef.current;
if (el) {
el.scrollIntoView({ block: "nearest" });
el.querySelector<HTMLElement>("input, textarea")?.focus();
}
onAutoFocused?.();
}, [autoFocusValue, onAutoFocused]);
const handleLabelClick = useCallback(() => {
onMenuOpenChange(!menuOpened);
}, [menuOpened, onMenuOpenChange]);
const handleMenuClose = useCallback(() => {
onMenuOpenChange(false);
}, [onMenuOpenChange]);
const Icon = getDescriptor(property.type)?.icon;
const label = (
<>
{Icon && <Icon size={15} className={classes.propertyLabelIcon} />}
<span className={classes.propertyLabelText}>{property.name}</span>
</>
);
return (
<div className={classes.propertyRow} ref={rowRef}>
{canEdit ? (
<Popover
opened={menuOpened}
position="bottom-start"
shadow="md"
width={260}
withinPortal
closeOnClickOutside={false}
closeOnEscape={false}
>
<Popover.Target>
<button
type="button"
className={clsx(classes.propertyLabel, classes.propertyLabelButton, {
[classes.propertyLabelActive]: menuOpened,
})}
onClick={handleLabelClick}
data-property-menu-target
>
{label}
<IconChevronDown size={13} className={classes.propertyLabelChevron} />
</button>
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<PropertyMenuContent
property={property}
opened={menuOpened}
onClose={handleMenuClose}
onDirtyChange={onMenuDirtyChange}
pageId={pageId}
/>
</Popover.Dropdown>
</Popover>
) : (
<div className={classes.propertyLabel}>{label}</div>
)}
<DetailField
property={property}
row={row}
readOnly={!canEdit}
onUpdate={onUpdate}
/>
</div>
);
}
@@ -0,0 +1,438 @@
import { Menu, Modal, Skeleton, Text, Tooltip } from "@mantine/core";
import { useWindowEvent } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { modals } from "@mantine/modals";
import {
IconChevronDown,
IconChevronUp,
IconDotsVertical,
IconLink,
IconLock,
IconPlus,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IBase, IBaseRow } from "@/ee/base/types/base.types";
import {
useBaseRowQuery,
useDeleteRowMutation,
useUpdateRowMutation,
} from "@/ee/base/queries/base-row-query";
import { propertyMenuCloseRequestAtomFamily } from "@/ee/base/atoms/base-atoms";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { useClipboard } from "@/hooks/use-clipboard";
import { CreatePropertyPopover } from "@/ee/base/components/property/create-property-popover";
import { RowDetailTitle } from "./row-detail-title";
import { PropertyRow } from "./property-row";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
type RowDetailModalProps = {
base: IBase;
rows: IBaseRow[];
openRowId: string | null;
onClose: () => void;
onNavigate: (rowId: string) => void;
};
export function RowDetailModal({
base,
rows,
openRowId,
onClose,
onNavigate,
}: RowDetailModalProps) {
const { t } = useTranslation();
const canEdit = useBaseEditable();
const updateRowMutation = useUpdateRowMutation();
const deleteRowMutation = useDeleteRowMutation();
const clipboard = useClipboard({ timeout: 500 });
const rowIndex = useMemo(
() => (openRowId ? rows.findIndex((r) => r.id === openRowId) : -1),
[openRowId, rows],
);
const rowFromList = rowIndex >= 0 ? rows[rowIndex] : undefined;
// Deep links (?row=) can target rows outside the loaded pages or filtered
// out of the active view — fetch by id instead of closing. Close only
// when the server confirms the row is gone.
const rowQuery = useBaseRowQuery(base.id, openRowId ?? undefined, {
enabled: !!openRowId && !rowFromList,
});
const row = rowFromList ?? rowQuery.data;
const primaryProperty = useMemo(
() => base.properties.find((p) => p.isPrimary),
[base.properties],
);
const rowMissing = !!openRowId && !rowFromList && rowQuery.isError;
useEffect(() => {
if (rowMissing) onClose();
}, [rowMissing, onClose]);
const isSaving = updateRowMutation.isPending;
const opened = !!openRowId;
// One field menu open at a time, mirroring the grid header's semantics.
// The shared closeRequest atom asks an open dirty PropertyMenuContent to
// run its discard-confirm flow instead of being torn down mid-edit.
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
const clearNewProperty = useCallback(() => setNewPropertyId(null), []);
const menuDirtyRef = useRef(false);
const [closeRequest, setCloseRequest] = useAtom(
propertyMenuCloseRequestAtomFamily(base.id),
) as unknown as [number, (val: number) => void];
useEffect(() => {
setOpenMenuId(null);
menuDirtyRef.current = false;
}, [openRowId]);
const handleMenuDirtyChange = useCallback((dirty: boolean) => {
menuDirtyRef.current = dirty;
}, []);
const requestMenuClose = useCallback(() => {
if (menuDirtyRef.current) {
setCloseRequest(closeRequest + 1);
} else {
setOpenMenuId(null);
}
}, [closeRequest, setCloseRequest]);
const handleMenuOpenChange = useCallback(
(propertyId: string, nextOpened: boolean) => {
if (!nextOpened) {
setOpenMenuId(null);
menuDirtyRef.current = false;
return;
}
if (openMenuId && openMenuId !== propertyId && menuDirtyRef.current) {
setCloseRequest(closeRequest + 1);
return;
}
setOpenMenuId(propertyId);
},
[openMenuId, closeRequest, setCloseRequest],
);
useEffect(() => {
if (!openMenuId) return;
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest("[data-position]")) return;
if (target.closest("[data-property-menu-target]")) return;
requestMenuClose();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [openMenuId, requestMenuClose]);
const hasPrev = rowIndex > 0;
const hasNext = rowIndex >= 0 && rowIndex < rows.length - 1;
const navigate = useCallback(
(delta: number) => {
if (rowIndex === -1) return;
const next = rows[rowIndex + delta];
if (next) onNavigate(next.id);
},
[rows, rowIndex, onNavigate],
);
const handleCopyLink = useCallback(() => {
clipboard.copy(window.location.href);
notifications.show({ message: t("Link copied") });
}, [clipboard, t]);
const handleDeleteRecord = useCallback(() => {
if (!row) return;
const rowId = row.id;
modals.openConfirmModal({
title: t("Delete record?"),
centered: true,
children: <Text size="sm">{t("This action cannot be undone.")}</Text>,
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
deleteRowMutation.mutate({ rowId, pageId: base.id });
onClose();
},
});
}, [row, base.id, deleteRowMutation, onClose, t]);
// Mantine's closeOnEscape runs a capture-phase window listener that fires
// before inner popovers and inputs see the key, so we manage Esc ourselves
// and yield to: nested dialogs (delete confirm), open popovers
// ([data-position]) and editable elements. Arrows step records under the
// same yield rules. Mantine puts role="dialog" and our content class on
// the same element, which distinguishes this modal from nested ones.
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
const isEscape = event.key === "Escape";
const isArrow = event.key === "ArrowUp" || event.key === "ArrowDown";
if ((!isEscape && !isArrow) || event.isComposing || !opened) return;
const target = event.target as HTMLElement | null;
if (target) {
const dialog = target.closest('[role="dialog"]');
if (dialog && !dialog.classList.contains(classes.modalContent)) {
return;
}
if (
target.closest("[data-position]") ||
target.matches("input, textarea, select, [contenteditable='true']")
) {
return;
}
}
if (isEscape) {
if (openMenuId) {
requestMenuClose();
return;
}
onClose();
return;
}
if (openMenuId) return;
event.preventDefault();
navigate(event.key === "ArrowUp" ? -1 : 1);
},
[opened, openMenuId, requestMenuClose, onClose, navigate],
);
useWindowEvent("keydown", handleKeyDown, { capture: true });
return (
<Modal
opened={opened}
onClose={onClose}
size="lg"
centered
withCloseButton={false}
closeOnEscape={false}
closeOnClickOutside={!openMenuId}
padding={0}
radius="md"
title={null}
classNames={{ content: classes.modalContent }}
>
{row ? (
<>
<div className={classes.topBar}>
<div className={classes.topBarGroup}>
<Tooltip label={t("Previous record")} openDelay={400}>
<button
type="button"
className={classes.iconButton}
onClick={() => navigate(-1)}
disabled={!hasPrev}
aria-label={t("Previous record")}
>
<IconChevronUp size={16} />
</button>
</Tooltip>
<Tooltip label={t("Next record")} openDelay={400}>
<button
type="button"
className={classes.iconButton}
onClick={() => navigate(1)}
disabled={!hasNext}
aria-label={t("Next record")}
>
<IconChevronDown size={16} />
</button>
</Tooltip>
</div>
<div className={classes.topBarGroup}>
<Menu position="bottom-end" shadow="md" withinPortal>
<Menu.Target>
<button
type="button"
className={classes.iconButton}
aria-label={t("Record actions")}
>
<IconDotsVertical size={16} />
</button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLink size={14} />}
onClick={handleCopyLink}
>
{t("Copy link")}
</Menu.Item>
{canEdit && (
<>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={handleDeleteRecord}
>
{t("Delete record")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
<button
type="button"
className={classes.iconButton}
onClick={onClose}
aria-label={t("Close")}
>
<IconX size={16} />
</button>
</div>
</div>
<RowDetailTitle
row={row}
primaryProperty={primaryProperty}
canEdit={canEdit}
onCommit={(value) => {
if (!primaryProperty) return;
updateRowMutation.mutate({
rowId: row.id,
pageId: base.id,
cells: { [primaryProperty.id]: value },
});
}}
/>
<div className={classes.body}>
<div className={classes.propertyList}>
{base.properties
.filter((p) => !p.isPrimary)
.map((property) => (
<PropertyRow
key={property.id}
property={property}
row={row}
pageId={base.id}
autoFocusValue={property.id === newPropertyId}
onAutoFocused={clearNewProperty}
menuOpened={openMenuId === property.id}
onMenuOpenChange={(nextOpened) =>
handleMenuOpenChange(property.id, nextOpened)
}
onMenuDirtyChange={handleMenuDirtyChange}
onUpdate={(propertyId, value) => {
updateRowMutation.mutate({
rowId: row.id,
pageId: base.id,
cells: { [propertyId]: value },
});
}}
/>
))}
</div>
{canEdit && (
<CreatePropertyPopover
pageId={base.id}
properties={base.properties}
onPropertyCreated={(p) => setNewPropertyId(p.id)}
renderTarget={(open) => (
<button
type="button"
className={classes.addPropertyRow}
onClick={open}
>
<span className={classes.addPropertyLabel}>
<IconPlus size={15} />
{t("Add property")}
</span>
</button>
)}
/>
)}
</div>
<footer className={classes.footer}>
<div className={classes.footerStatus}>
{!canEdit ? (
<span className={classes.lockedHint}>
<IconLock size={12} />
{t("Read-only")}
</span>
) : isSaving ? (
<>
<span className={classes.savingDot} />
<span>{t("Saving…")}</span>
</>
) : null}
</div>
<div className={classes.kbdHint}>
{rowIndex >= 0 && rows.length > 1 && (
<>
<kbd className={classes.kbd}></kbd>
<kbd className={classes.kbd}></kbd>
<span>{t("to navigate")}</span>
<span className={classes.kbdSeparator} />
</>
)}
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to close")}</span>
</div>
</footer>
</>
) : (
<RowDetailSkeleton base={base} />
)}
</Modal>
);
}
/** Hydration state for deep-linked rows: the schema is already loaded, so
* render the real labels and shimmer only the unknown values. Matching the
* final layout avoids a size jump when the row arrives. */
function RowDetailSkeleton({ base }: { base: IBase }) {
return (
<>
<div className={classes.topBar}>
<div className={classes.topBarGroup}>
<Skeleton height={28} width={28} radius={6} />
<Skeleton height={28} width={28} radius={6} />
</div>
<div className={classes.topBarGroup}>
<Skeleton height={28} width={28} radius={6} />
<Skeleton height={28} width={28} radius={6} />
</div>
</div>
<header className={classes.header}>
<Skeleton height={30} width="45%" radius={8} />
<div className={classes.metaRow}>
<Skeleton height={12} width={150} radius={4} />
</div>
</header>
<div className={classes.body}>
<div className={classes.propertyList}>
{base.properties
.filter((p) => !p.isPrimary)
.map((property) => {
const Icon = getDescriptor(property.type)?.icon;
return (
<div key={property.id} className={classes.propertyRow}>
<div className={classes.propertyLabel}>
{Icon && (
<Icon size={15} className={classes.propertyLabelIcon} />
)}
<span className={classes.propertyLabelText}>
{property.name}
</span>
</div>
<Skeleton
height={property.type === "longText" ? 82 : 34}
radius={7}
style={{ flex: 1 }}
/>
</div>
);
})}
</div>
</div>
</>
);
}
@@ -0,0 +1,73 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { timeAgo } from "@/lib/time.ts";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
type RowDetailTitleProps = {
row: IBaseRow;
primaryProperty: IBaseProperty | undefined;
canEdit: boolean;
onCommit: (value: string) => void;
};
export function RowDetailTitle({
row,
primaryProperty,
canEdit,
onCommit,
}: RowDetailTitleProps) {
const { t } = useTranslation();
const initial = primaryProperty
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
: "";
const [value, setValue] = useState(initial);
const inputRef = useRef<HTMLInputElement>(null);
const didAutofocusRef = useRef(false);
// Re-sync when the row changes underneath us (navigation or remote edit).
useEffect(() => {
setValue(initial);
}, [initial]);
useEffect(() => {
if (didAutofocusRef.current || !canEdit || initial) return;
didAutofocusRef.current = true;
inputRef.current?.focus();
}, [canEdit, initial]);
const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : "";
return (
<header className={classes.header}>
{canEdit ? (
<input
ref={inputRef}
type="text"
className={classes.titleInput}
placeholder={t("Untitled")}
aria-label={primaryProperty?.name ?? t("Untitled")}
value={value}
maxLength={1000}
onChange={(e) => setValue(e.currentTarget.value)}
onBlur={() => {
if (value !== initial) onCommit(value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
}
}}
/>
) : (
<h1 className={classes.titleStatic}>{value || t("Untitled")}</h1>
)}
{updatedAgo && (
<div className={classes.metaRow}>
<span>{t("Updated {{when}}", { when: updatedAgo })}</span>
</div>
)}
</header>
);
}