feat(bases): row detail modal shell with editable title

This commit is contained in:
Philipinho
2026-05-24 16:04:29 +01:00
parent e071de9248
commit 9237e94769
3 changed files with 183 additions and 45 deletions
@@ -39,6 +39,8 @@ import { BaseToolbar } from "@/features/base/components/base-toolbar";
import { BaseViewDraftBanner } from "@/features/base/components/base-view-draft-banner";
import { BaseTableSkeleton } from "@/features/base/components/base-table-skeleton";
import { ViewRenderer } from "@/features/base/components/views/view-renderer";
import { useRowDetailModal } from "@/features/base/hooks/use-row-detail-modal";
import { RowDetailModal } from "@/features/base/components/row-detail-modal/row-detail-modal";
import classes from "@/features/base/styles/grid.module.css";
type BaseViewProps = {
@@ -267,12 +269,11 @@ export function BaseView({ pageId, embedded }: BaseViewProps) {
updateViewMutation,
]);
const handleCardClick = useCallback((rowId: string) => {
// Phase 5 wires this to the URL-driven row detail modal.
// Until then, noop — the click still registers and the focus ring
// appears, but nothing opens.
void rowId;
}, []);
const { openRowId, openRow, closeRow } = useRowDetailModal();
const handleCardClick = useCallback(
(rowId: string) => openRow(rowId),
[openRow],
);
const handleRowReorder = useCallback(
(rowId: string, targetRowId: string, dropPosition: "above" | "below") => {
@@ -350,44 +351,7 @@ export function BaseView({ pageId, embedded }: BaseViewProps) {
// Inline: banner + toolbar live inside the StickyBand (passed via
// stickyBandPrelude). The page is the vertical scroll container.
return (
<ViewRenderer
base={base}
rows={rows}
effectiveView={effectiveView}
table={table}
pageId={pageId}
embedded={embedded}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
onColumnReorder={handleColumnReorder}
onResizeEnd={handleResizeEnd}
onRowReorder={handleRowReorder}
onCardClick={handleCardClick}
persistViewConfig={persistViewConfig}
scrollportRef={scrollportRef}
stickyBandPrelude={
<>
{banner}
{toolbar}
</>
}
/>
);
}
// Standalone: banner + toolbar sit above the .tableScrollport, which
// is the vertical scroll container. StickyBand inside contains only
// the column-header row.
return (
<div
style={{ display: "flex", flexDirection: "column", height: "100%" }}
>
{banner}
{toolbar}
<div className={classes.tableScrollport} ref={scrollportRef}>
<>
<ViewRenderer
base={base}
rows={rows}
@@ -406,8 +370,61 @@ export function BaseView({ pageId, embedded }: BaseViewProps) {
onCardClick={handleCardClick}
persistViewConfig={persistViewConfig}
scrollportRef={scrollportRef}
stickyBandPrelude={
<>
{banner}
{toolbar}
</>
}
/>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
/>
</>
);
}
// Standalone: banner + toolbar sit above the .tableScrollport, which
// is the vertical scroll container. StickyBand inside contains only
// the column-header row.
return (
<>
<div
style={{ display: "flex", flexDirection: "column", height: "100%" }}
>
{banner}
{toolbar}
<div className={classes.tableScrollport} ref={scrollportRef}>
<ViewRenderer
base={base}
rows={rows}
effectiveView={effectiveView}
table={table}
pageId={pageId}
embedded={embedded}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
onColumnReorder={handleColumnReorder}
onResizeEnd={handleResizeEnd}
onRowReorder={handleRowReorder}
onCardClick={handleCardClick}
persistViewConfig={persistViewConfig}
scrollportRef={scrollportRef}
/>
</div>
</div>
</div>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
/>
</>
);
}
@@ -0,0 +1,74 @@
import { Modal, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useEffect, useMemo } from "react";
import {
IBase,
IBaseRow,
} from "@/features/base/types/base.types";
import { useUpdateRowMutation } from "@/features/base/queries/base-row-query";
import { RowDetailTitle } from "./row-detail-title";
type RowDetailModalProps = {
base: IBase;
rows: IBaseRow[];
openRowId: string | null;
onClose: () => void;
};
export function RowDetailModal({
base,
rows,
openRowId,
onClose,
}: RowDetailModalProps) {
const { t } = useTranslation();
const updateRowMutation = useUpdateRowMutation();
const row = useMemo(
() => (openRowId ? rows.find((r) => r.id === openRowId) : undefined),
[openRowId, rows],
);
const primaryProperty = useMemo(
() => base.properties.find((p) => p.isPrimary),
[base.properties],
);
// If a row was open and disappeared (deleted, filtered out, or not yet
// loaded into the rows page), close the modal.
const wasOpen = !!openRowId && !row;
useEffect(() => {
if (wasOpen) onClose();
}, [wasOpen, onClose]);
return (
<Modal
opened={!!row}
onClose={onClose}
size="lg"
withCloseButton
centered
title={null}
>
{row ? (
<Stack gap="md">
<RowDetailTitle
row={row}
primaryProperty={primaryProperty}
onCommit={(value) => {
if (!primaryProperty) return;
updateRowMutation.mutate({
rowId: row.id,
pageId: base.id,
cells: { [primaryProperty.id]: value },
});
}}
/>
{/* Property list goes here in Task 19 */}
{/* Add-property button goes here in Task 20 */}
</Stack>
) : (
<Text c="dimmed">{t("Loading…")}</Text>
)}
</Modal>
);
}
@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IBaseProperty, IBaseRow } from "@/features/base/types/base.types";
type RowDetailTitleProps = {
row: IBaseRow;
primaryProperty: IBaseProperty | undefined;
onCommit: (value: string) => void;
};
export function RowDetailTitle({
row,
primaryProperty,
onCommit,
}: RowDetailTitleProps) {
const { t } = useTranslation();
const initial = primaryProperty
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
: "";
const [value, setValue] = useState(initial);
// Re-sync if the underlying row changes (e.g. another client updated it).
useEffect(() => {
setValue(initial);
}, [initial]);
return (
<TextInput
autoFocus
placeholder={t("Untitled")}
value={value}
variant="unstyled"
size="xl"
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();
}
}}
/>
);
}