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(null); const [newPropertyId, setNewPropertyId] = useState(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: {t("This action cannot be undone.")}, 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 ( {row ? ( <>
} onClick={handleCopyLink} > {t("Copy link")} {canEdit && ( <> } onClick={handleDeleteRecord} > {t("Delete record")} )}
{ if (!primaryProperty) return; updateRowMutation.mutate({ rowId: row.id, pageId: base.id, cells: { [primaryProperty.id]: value }, }); }} />
{base.properties .filter((p) => !p.isPrimary) .map((property) => ( handleMenuOpenChange(property.id, nextOpened) } onMenuDirtyChange={handleMenuDirtyChange} onUpdate={(propertyId, value) => { updateRowMutation.mutate({ rowId: row.id, pageId: base.id, cells: { [propertyId]: value }, }); }} /> ))}
{canEdit && ( setNewPropertyId(p.id)} renderTarget={(open) => ( )} /> )}
{!canEdit ? ( {t("Read-only")} ) : isSaving ? ( <> {t("Saving…")} ) : null}
{rowIndex >= 0 && rows.length > 1 && ( <> {t("to navigate")} )} Esc {t("to close")}
) : ( )}
); } /** 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 ( <>
{base.properties .filter((p) => !p.isPrimary) .map((property) => { const Icon = getDescriptor(property.type)?.icon; return (
{Icon && ( )} {property.name}
); })}
); }