mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-06-18 09:02:45 +03:00
5297 lines
219 KiB
TypeScript
5297 lines
219 KiB
TypeScript
import {
|
||
Alert,
|
||
Badge,
|
||
Box,
|
||
Button,
|
||
Checkbox,
|
||
Chip,
|
||
CircularProgress,
|
||
Collapse,
|
||
Dialog,
|
||
DialogActions,
|
||
DialogContent,
|
||
DialogTitle,
|
||
IconButton,
|
||
LinearProgress,
|
||
MenuItem,
|
||
Paper,
|
||
Stack,
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableContainer,
|
||
TableHead,
|
||
TablePagination,
|
||
TableRow,
|
||
TableSortLabel,
|
||
TextField,
|
||
Tooltip,
|
||
Typography,
|
||
useMediaQuery,
|
||
useTheme,
|
||
} from "@mui/material";
|
||
import AddIcon from "@mui/icons-material/Add";
|
||
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||
import AutorenewIcon from "@mui/icons-material/Autorenew";
|
||
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
|
||
import DeleteIcon from "@mui/icons-material/Delete";
|
||
import EditIcon from "@mui/icons-material/Edit";
|
||
import FilterAltIcon from "@mui/icons-material/FilterAlt";
|
||
import FilterAltOffIcon from "@mui/icons-material/FilterAltOff";
|
||
import InboxRoundedIcon from "@mui/icons-material/InboxRounded";
|
||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
|
||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
||
import SearchIcon from "@mui/icons-material/Search";
|
||
import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore";
|
||
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
||
import dayjs, { type Dayjs } from "dayjs";
|
||
import {
|
||
forwardRef,
|
||
memo,
|
||
useCallback,
|
||
useEffect,
|
||
useLayoutEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type KeyboardEvent,
|
||
type ReactNode,
|
||
} from "react";
|
||
import type { Listable } from "../api/types";
|
||
import { notifyApiError, useNotify } from "../notifications/NotificationsProvider";
|
||
import { PageHeader } from "./PageHeader";
|
||
|
||
export type FieldType = "text" | "number" | "select" | "multiselect" | "ids" | "uuid" | "string-list";
|
||
|
||
// FILTER_WIDTH is the fixed CSS width (px) of a single filter cell in the
|
||
// filter panel. FILTER_GAP is the flex gap between cells (matches the
|
||
// `gap: 1.5` on the wrapping Box: MUI 8 px spacing × 1.5 = 12 px).
|
||
const FILTER_WIDTH = 240;
|
||
const FILTER_GAP = 12;
|
||
// "wide" filter cells (datetime-range, or any text/select cell with
|
||
// `wide: true`) span 1.5 "slots" of the grid. A 1-slot stretch across
|
||
// the gap accounts for half a gap (because skipping ½ a slot also skips
|
||
// ½ of its trailing gap), so the resulting outer box width snaps cleanly
|
||
// to the half-grid line.
|
||
const WIDE_FILTER_WIDTH = FILTER_WIDTH * 1.5 + FILTER_GAP * 0.5;
|
||
|
||
// generateUUID returns an RFC 4122 v4 UUID. Browsers > 2022 expose
|
||
// crypto.randomUUID; older ones get a manual implementation backed by
|
||
// crypto.getRandomValues (always available where fetch is).
|
||
export function generateUUID(): string {
|
||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||
return crypto.randomUUID();
|
||
}
|
||
const bytes = new Uint8Array(16);
|
||
crypto.getRandomValues(bytes);
|
||
bytes[6] = (bytes[6]! & 0x0f) | 0x40; // version 4
|
||
bytes[8] = (bytes[8]! & 0x3f) | 0x80; // variant 10xxxxxx
|
||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||
}
|
||
|
||
export interface FieldSpec<TValue = unknown> {
|
||
name: string;
|
||
label: string;
|
||
type: FieldType;
|
||
required?: boolean;
|
||
options?: { value: string; label?: string }[];
|
||
helperText?: string;
|
||
// Used by select/multiselect to render labels.
|
||
defaultValue?: TValue;
|
||
// If set, only show in create form / only in edit form.
|
||
only?: "create" | "update";
|
||
// Optional dynamic visibility — receives the current form values, returns
|
||
// false to hide the field. Hidden fields are not rendered and not
|
||
// submitted. Mirrors `FieldOnChooseOptionsHide` from the legacy admin.
|
||
visibleWhen?: (form: Record<string, unknown>) => boolean;
|
||
// When this field's value changes, every name listed here is reset to its
|
||
// empty default. Used e.g. on the user `type` select to wipe credential
|
||
// fields when the type changes.
|
||
clears?: string[];
|
||
// Async option loader, fired once when the dialog opens. The resolved list
|
||
// takes precedence over `options` while rendering. Used by squad_ids on
|
||
// every page so the multi-select is populated from GET /squads.
|
||
optionsLoader?: () => Promise<{ value: string; label?: string }[]>;
|
||
// For `multiselect` fields: render the chosen values as an ordered
|
||
// pipeline (`User → Destination → IP`) rather than as independent
|
||
// chips. Used by ordered/queue-style fields like `flow_keys`.
|
||
displayAsChain?: boolean;
|
||
}
|
||
|
||
// emptyValueForField returns the canonical "empty" value for a given field
|
||
// spec, matching what `emptyForm` would produce.
|
||
function emptyValueForField(f: FieldSpec): unknown {
|
||
if (f.defaultValue !== undefined) return f.defaultValue;
|
||
if (f.type === "multiselect" || f.type === "string-list") return [];
|
||
return "";
|
||
}
|
||
|
||
export interface ColumnSpec<TEntity> {
|
||
key: keyof TEntity | string;
|
||
label: string;
|
||
render?: (row: TEntity) => ReactNode;
|
||
// Whether the user can click the column header to sort by this column.
|
||
// Defaults to `true` — pages explicitly opt-out for non-comparable
|
||
// columns like `squad_ids` (array) or `status` (computed remote field).
|
||
sortable?: boolean;
|
||
}
|
||
|
||
// RowActionSpec is a per-row IconButton rendered in the actions column,
|
||
// to the *left* of the built-in Edit / Delete buttons. Pages use it for
|
||
// row-scoped operations that aren't a direct field edit — e.g. the
|
||
// Traffic Limiters page exposes a "reset traffic" action this way.
|
||
//
|
||
// `onClick` receives a small context object with `reload`, the same
|
||
// callback CrudPage uses internally after Create / Edit / Delete; calling
|
||
// it after the action completes refreshes the table so the new row state
|
||
// (e.g. `usage` ticked back to 0) appears without forcing the user to
|
||
// hit Refresh.
|
||
export interface RowActionSpec<TEntity> {
|
||
// Stable identifier — used as the React key for the rendered button so
|
||
// toggling `visible` on / off doesn't churn the DOM.
|
||
key: string;
|
||
// Tooltip text and aria-label for the button.
|
||
label: string;
|
||
// Icon node, e.g. `<RestartAltIcon />`.
|
||
icon: ReactNode;
|
||
// Hover tint preset. "default" matches the EditIcon button (primary
|
||
// hover), "danger" matches the DeleteIcon button (red hover).
|
||
variant?: "default" | "danger";
|
||
// Optional predicate; when supplied, the button is hidden for rows
|
||
// that fail the check. Defaults to "always show".
|
||
visible?: (row: TEntity) => boolean;
|
||
// Optional confirmation step. When provided, clicking the action
|
||
// opens a centred Dialog (same chrome as the Delete one) with the
|
||
// returned title / description and a primary button labelled
|
||
// `confirmLabel`. If omitted, `onClick` runs immediately on click.
|
||
confirm?: (row: TEntity) => {
|
||
title: string;
|
||
description: string;
|
||
// Primary button label. Defaults to `action.label`.
|
||
confirmLabel?: string;
|
||
// Label shown while `onClick` is in flight. Defaults to
|
||
// `confirmLabel + "…"`.
|
||
busyLabel?: string;
|
||
// MUI palette for the primary button. Defaults to "primary".
|
||
// `"error"` matches the destructive Delete dialog so dangerous
|
||
// actions can reuse the same red treatment.
|
||
color?: "primary" | "warning" | "error";
|
||
};
|
||
// The action callback. May be async — pages typically await the API
|
||
// request and then call `ctx.reload()` to refresh the table. When
|
||
// `confirm` is set, this only runs after the user clicks the
|
||
// primary button in the dialog.
|
||
onClick: (row: TEntity, ctx: { reload: () => Promise<void> }) => Promise<void> | void;
|
||
}
|
||
|
||
// FilterSpec describes a column filter rendered above the table.
|
||
//
|
||
// The filter value is sent as a query parameter on the list() call. For
|
||
// `datetime-range` two parameters are sent: `${name}_start` / `${name}_end`.
|
||
export type FilterType = "text" | "select" | "datetime-range";
|
||
export interface FilterSpec {
|
||
name: string;
|
||
label: string;
|
||
type: FilterType;
|
||
options?: { value: string; label?: string }[];
|
||
placeholder?: string;
|
||
// Render the filter cell at 1.5× the regular width — same slot size as
|
||
// a `datetime-range` cell. Useful for inputs whose typical content is
|
||
// long enough that the default 240 px feels cramped (e.g. a 36-char
|
||
// UUID or a long URL). The slot still aligns with the half-grid line
|
||
// so the surrounding flex-wrap layout stays tidy.
|
||
wide?: boolean;
|
||
}
|
||
|
||
// optionLabel maps a raw select value to its human-readable label, matching
|
||
// service/admin_panel/tables/utils.go's optionLabelDisplay. Falls back to
|
||
// the raw stringified value when no option matches (e.g. unknown enum
|
||
// returned by the API).
|
||
export function optionLabel(
|
||
options: { value: string; label?: string }[] | undefined,
|
||
value: unknown,
|
||
): string {
|
||
if (value === null || value === undefined) return "";
|
||
const v = String(value);
|
||
const found = options?.find((o) => o.value === v);
|
||
return found?.label ?? v;
|
||
}
|
||
|
||
// renderOptionLabel returns a `render` function for a ColumnSpec that maps
|
||
// the row's single-value field to its option label. Use on `select`-type
|
||
// columns so the table shows "Hysteria" instead of "hysteria".
|
||
export function renderOptionLabel<TEntity>(
|
||
key: keyof TEntity | string,
|
||
options: { value: string; label?: string }[],
|
||
): (row: TEntity) => ReactNode {
|
||
return (row: TEntity) => {
|
||
const v = (row as Record<string, unknown>)[key as string];
|
||
if (v === null || v === undefined || v === "") return "";
|
||
return optionLabel(options, v);
|
||
};
|
||
}
|
||
|
||
// Module-level style constants shared by every TableRow so React doesn't
|
||
// allocate fresh sx objects (and emotion doesn't re-hash CSS classes) on
|
||
// every row render. Theme tokens (text.secondary, action.active) are
|
||
// used instead of hardcoded white-with-alpha so the row reads correctly
|
||
// in both dark and light mode.
|
||
// Body cells inherit their column's width from the header under
|
||
// `tableLayout: fixed`. We clip overflow with ellipsis so a value longer
|
||
// than the user's chosen column width doesn't bleed into the next column.
|
||
const BODY_CELL_SX = {
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap" as const,
|
||
};
|
||
const ID_CELL_SX = {
|
||
fontFamily:
|
||
'"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
|
||
fontSize: 12.5,
|
||
color: "text.secondary",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap" as const,
|
||
} as const;
|
||
// Actions are pinned to the right edge of the table so the Edit /
|
||
// Delete buttons stay reachable no matter how wide the data columns
|
||
// grow or how far the user has scrolled the table horizontally.
|
||
// `position: sticky` + `right: 0` glues the cell to the right edge
|
||
// of its scroll container; the z-index hierarchy (header > body >
|
||
// data columns) makes the pinned column win overlap so the data
|
||
// columns slide visually under it.
|
||
//
|
||
// To avoid showing the scrolled-under column text through the
|
||
// pinned cell, the body / header cells need an opaque background
|
||
// that matches whatever colour the surrounding row / head currently
|
||
// renders. The theme paints solid colours for each state (see
|
||
// theme.ts):
|
||
//
|
||
// - resting body row → `--sb-surface` (Table backgroundColor)
|
||
// - hovered body row → `--sb-elevated2` (TableRow `:hover` !important)
|
||
// - header row → `--sb-elevated` (MuiTableHead bg)
|
||
//
|
||
// Selected (no hover) is the only state MUI itself paints with a
|
||
// translucent accent overlay rather than a custom solid; we mimic
|
||
// it with `color-mix` so the sticky cell ends up at the same final
|
||
// pixel colour as the surrounding cells. Selected + hover is back
|
||
// to solid `--sb-elevated2` because the `:hover` override in the
|
||
// theme uses `!important` and wins over `Mui-selected`.
|
||
// Soft inset-style shadow on the left edge of the pinned Actions
|
||
// column. The negative spread (-4px) clips the blur to a thin band
|
||
// near the boundary, so it reads as a subtle separator when other
|
||
// content sits to the left without bleeding far into the cell.
|
||
const ACTIONS_CELL_SHADOW = "-4px 0 6px -4px rgba(0,0,0,0.18)";
|
||
const ACTIONS_CELL_SX = {
|
||
paddingLeft: "8px",
|
||
paddingRight: "8px",
|
||
whiteSpace: "nowrap" as const,
|
||
position: "sticky" as const,
|
||
right: 0,
|
||
zIndex: 2,
|
||
backgroundColor: "var(--sb-surface)",
|
||
boxShadow: ACTIONS_CELL_SHADOW,
|
||
// Mirror the timing curve of `MuiTableRow` (`background-color 0.14s
|
||
// ease` in theme.ts). Without this the sticky cell snaps to its new
|
||
// background instantly while the rest of the row eases in, which
|
||
// reads as a brief asymmetric flash on hover / unhover.
|
||
transition: "background-color 0.14s ease",
|
||
"tr:hover &": {
|
||
backgroundColor: "var(--sb-elevated2)",
|
||
},
|
||
"tr.Mui-selected &": {
|
||
backgroundColor:
|
||
"color-mix(in srgb, var(--sb-accent) 8%, var(--sb-surface))",
|
||
},
|
||
"tr.Mui-selected:hover &": {
|
||
backgroundColor: "var(--sb-elevated2)",
|
||
},
|
||
};
|
||
const ACTIONS_HEADER_CELL_SX = {
|
||
width: 92,
|
||
paddingLeft: "8px",
|
||
paddingRight: "8px",
|
||
textAlign: "center" as const,
|
||
position: "sticky" as const,
|
||
right: 0,
|
||
zIndex: 3,
|
||
backgroundColor: "var(--sb-elevated)",
|
||
boxShadow: ACTIONS_CELL_SHADOW,
|
||
};
|
||
// Filler cell — the only auto-width cell in each row. Padding stripped
|
||
// so it can collapse cleanly to 0 px width once the fixed columns
|
||
// overflow the viewport (any padding would otherwise add width that
|
||
// `tableLayout: fixed` can't fully reclaim). The default
|
||
// `border-bottom` from MuiTableCell is preserved on purpose so the
|
||
// row's horizontal divider extends across the empty span — without
|
||
// it the rule would visually break wherever the filler sits.
|
||
const FILLER_CELL_SX = {
|
||
padding: 0,
|
||
} as const;
|
||
const EDIT_BTN_SX = { width: 28, height: 28 } as const;
|
||
const DELETE_BTN_SX = {
|
||
width: 28,
|
||
height: 28,
|
||
color: "text.secondary",
|
||
"&:hover": {
|
||
color: "error.main",
|
||
backgroundColor: "rgba(239,68,68,0.12)",
|
||
},
|
||
} as const;
|
||
|
||
// renderOptionChain renders an array of option values as an ordered queue,
|
||
// e.g. `User → Destination → IP` rather than a set of independent chips.
|
||
// The order of the array is preserved verbatim, so a column rendered with
|
||
// this helper visualises "first match wins" behaviour for fair-queue /
|
||
// pipeline style fields.
|
||
export function renderOptionChain<TEntity>(
|
||
key: keyof TEntity | string,
|
||
options: { value: string; label?: string }[],
|
||
): (row: TEntity) => ReactNode {
|
||
return (row: TEntity) => {
|
||
const raw = (row as Record<string, unknown>)[key as string];
|
||
if (!Array.isArray(raw) || raw.length === 0) return "";
|
||
return (
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
flexWrap: "wrap",
|
||
alignItems: "center",
|
||
rowGap: 0.5,
|
||
columnGap: 0.5,
|
||
maxWidth: "100%",
|
||
}}
|
||
>
|
||
{raw.map((v, i) => (
|
||
<Box
|
||
key={i}
|
||
component="span"
|
||
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
||
>
|
||
{i > 0 && (
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
color: "text.secondary",
|
||
fontSize: 12,
|
||
userSelect: "none",
|
||
}}
|
||
>
|
||
→
|
||
</Box>
|
||
)}
|
||
<Chip label={optionLabel(options, v)} size="small" />
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
);
|
||
};
|
||
}
|
||
|
||
export interface CrudConfig<TEntity, TCreate, TUpdate> {
|
||
title: string;
|
||
subtitle?: string;
|
||
// Icon shown next to the page title.
|
||
icon?: ReactNode;
|
||
idKey: keyof TEntity;
|
||
columns: ColumnSpec<TEntity>[];
|
||
fields: FieldSpec[];
|
||
// Optional filter bar. Each entry becomes an input rendered above the
|
||
// table; the applied values are forwarded to `list()` as query params.
|
||
filters?: FilterSpec[];
|
||
// Optional per-row IconButtons rendered before Edit/Delete in the
|
||
// actions column. See RowActionSpec for the contract.
|
||
rowActions?: RowActionSpec<TEntity>[];
|
||
// list receives the applied filter query (+ any extra query params you add
|
||
// to defaults later). Matches the signature of the generated API client.
|
||
list: (query?: Listable) => Promise<TEntity[]>;
|
||
// Optional total-row count. When provided, the table renders a pagination
|
||
// bar at the bottom and pages through the data via `offset` + `limit`. The
|
||
// count is fetched alongside `list` so the displayed range stays in sync.
|
||
count?: (query?: Listable) => Promise<number>;
|
||
create: (body: TCreate) => Promise<TEntity>;
|
||
update: (id: string | number, body: TUpdate) => Promise<TEntity>;
|
||
remove: (id: string | number) => Promise<TEntity>;
|
||
toCreate: (form: Record<string, unknown>) => TCreate;
|
||
toUpdate: (form: Record<string, unknown>, original: TEntity) => TUpdate;
|
||
// Optionally derive form values from the row when editing.
|
||
fromEntity?: (row: TEntity) => Record<string, unknown>;
|
||
rowKey?: (row: TEntity) => string;
|
||
// Default sort applied on first render. `field` is the column key; the
|
||
// direction maps to `sort_asc` / `sort_desc` query params.
|
||
defaultSort?: { field: string; dir: "asc" | "desc" };
|
||
// Initial / fallback page size; defaults to 10.
|
||
defaultPageSize?: number;
|
||
// Fired after every successful `list()` response, with the rows that
|
||
// are about to be rendered. Pages use it to fetch auxiliary data that
|
||
// is only needed for what is on screen — e.g. squad names for the
|
||
// rows in view — instead of pre-fetching the full catalog on mount.
|
||
// If the callback returns a promise, `reload` awaits it before
|
||
// publishing the new row set, so the table never flashes raw ids
|
||
// before the friendly labels arrive. The callback is best-effort:
|
||
// errors thrown (or rejections) are caught so a misbehaving observer
|
||
// can't break the table refresh.
|
||
onRowsChange?: (rows: TEntity[]) => void | Promise<void>;
|
||
}
|
||
|
||
// formatDatetimeForFilter converts a `<input type="datetime-local">` value
|
||
// ("YYYY-MM-DDTHH:MM" or "...:SS") into the "YYYY-MM-DD HH:MM:SS" shape the
|
||
// manager API expects.
|
||
//
|
||
// The repository stores TIMESTAMP columns as text like
|
||
// "2025-01-15 14:30:00.123456789+03:00" (modernc/sqlite's default time
|
||
// serialization) and the backend compares filter values to that text
|
||
// lexicographically. ISO-8601 strings ("...T...Z") sort *after* the
|
||
// space-separated stored values so any > filter would (incorrectly) match
|
||
// nothing — sending the value with a literal space here mirrors what the
|
||
// legacy admin_panel sends and is what makes the date filter actually work.
|
||
function formatDatetimeForFilter(input: string): string {
|
||
if (!input) return input;
|
||
const padded = input.length === 16 ? `${input}:00` : input;
|
||
return padded.replace("T", " ");
|
||
}
|
||
|
||
// buildFilterQuery takes the raw filter-state object and turns it into a
|
||
// Listable query suitable for the API client: empty strings are dropped,
|
||
// datetime-range fields are split into _start / _end params.
|
||
function buildFilterQuery(
|
||
specs: FilterSpec[],
|
||
values: Record<string, unknown>,
|
||
): Listable {
|
||
const out: Record<string, string | number | string[] | undefined> = {};
|
||
for (const f of specs) {
|
||
const raw = values[f.name];
|
||
if (f.type === "datetime-range") {
|
||
const r = (raw ?? {}) as { start?: string; end?: string };
|
||
if (r.start) out[`${f.name}_start`] = formatDatetimeForFilter(r.start);
|
||
if (r.end) out[`${f.name}_end`] = formatDatetimeForFilter(r.end);
|
||
continue;
|
||
}
|
||
if (typeof raw === "string" && raw.trim() !== "") {
|
||
out[f.name] = raw.trim();
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function countActiveFilters(
|
||
specs: FilterSpec[],
|
||
values: Record<string, unknown>,
|
||
): number {
|
||
let n = 0;
|
||
for (const f of specs) {
|
||
const raw = values[f.name];
|
||
if (f.type === "datetime-range") {
|
||
const r = (raw ?? {}) as { start?: string; end?: string };
|
||
if (r.start || r.end) n++;
|
||
continue;
|
||
}
|
||
if (typeof raw === "string" && raw.trim() !== "") n++;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
// filterValuesEqual compares two filter-state objects by spec, ignoring key
|
||
// insertion order. JSON.stringify-based equality used to false-positive
|
||
// "dirty" simply because the user typed filters in a different order, even
|
||
// when the canonical applied set matched the draft.
|
||
function filterValuesEqual(
|
||
specs: FilterSpec[],
|
||
a: Record<string, unknown>,
|
||
b: Record<string, unknown>,
|
||
): boolean {
|
||
for (const f of specs) {
|
||
const av = a[f.name];
|
||
const bv = b[f.name];
|
||
if (f.type === "datetime-range") {
|
||
const ar = (av ?? {}) as { start?: string; end?: string };
|
||
const br = (bv ?? {}) as { start?: string; end?: string };
|
||
if ((ar.start ?? "") !== (br.start ?? "")) return false;
|
||
if ((ar.end ?? "") !== (br.end ?? "")) return false;
|
||
continue;
|
||
}
|
||
const as = typeof av === "string" ? av : "";
|
||
const bs = typeof bv === "string" ? bv : "";
|
||
if (as !== bs) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function emptyForm(fields: FieldSpec[], mode: "create" | "update"): Record<string, unknown> {
|
||
const out: Record<string, unknown> = {};
|
||
for (const f of fields) {
|
||
if (f.only && f.only !== mode) continue;
|
||
if (f.defaultValue !== undefined) {
|
||
out[f.name] = f.defaultValue;
|
||
} else if (f.type === "multiselect") {
|
||
out[f.name] = [];
|
||
} else if (f.type === "ids") {
|
||
out[f.name] = "";
|
||
} else if (f.type === "number") {
|
||
out[f.name] = "";
|
||
} else {
|
||
out[f.name] = "";
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function parseIds(v: unknown): number[] {
|
||
if (Array.isArray(v)) {
|
||
return v
|
||
.map((x) => Number(x))
|
||
.filter((n) => Number.isFinite(n));
|
||
}
|
||
if (typeof v === "string") {
|
||
return v
|
||
.split(",")
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
.map((s) => Number(s))
|
||
.filter((n) => Number.isFinite(n));
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// fieldVisible reports whether a field should be displayed (and submitted)
|
||
// given the current mode + form values. Static `only` and dynamic
|
||
// `visibleWhen` rules are both honoured.
|
||
function fieldVisible(
|
||
f: FieldSpec,
|
||
mode: "create" | "update",
|
||
form: Record<string, unknown>,
|
||
): boolean {
|
||
if (f.only && f.only !== mode) return false;
|
||
if (f.visibleWhen && !f.visibleWhen(form)) return false;
|
||
return true;
|
||
}
|
||
|
||
// isFieldEmpty reports whether the user has provided no value for a field.
|
||
// The semantics match what the API would otherwise complain about: blank
|
||
// strings, missing selections, and empty arrays for multi-select / ids.
|
||
function isFieldEmpty(f: FieldSpec, value: unknown): boolean {
|
||
if (value === undefined || value === null) return true;
|
||
if (f.type === "multiselect" || f.type === "ids" || f.type === "string-list") {
|
||
if (Array.isArray(value)) return value.length === 0;
|
||
if (typeof value === "string") return value.trim() === "";
|
||
return true;
|
||
}
|
||
if (f.type === "number") {
|
||
if (value === "") return true;
|
||
return !Number.isFinite(Number(value));
|
||
}
|
||
if (typeof value === "string") return value.trim() === "";
|
||
return false;
|
||
}
|
||
|
||
// validateRequired returns a `{ field name -> message }` map describing
|
||
// every visible required field that the user has not filled in. The dialog
|
||
// uses this to block submit + render inline errors instead of letting the
|
||
// server reject the request.
|
||
export function validateRequired(
|
||
fields: FieldSpec[],
|
||
form: Record<string, unknown>,
|
||
): Record<string, string> {
|
||
const errors: Record<string, string> = {};
|
||
for (const f of fields) {
|
||
if (!f.required) continue;
|
||
if (isFieldEmpty(f, form[f.name])) errors[f.name] = "This field is required";
|
||
}
|
||
return errors;
|
||
}
|
||
|
||
export function normalizeFormForSubmit(
|
||
fields: FieldSpec[],
|
||
mode: "create" | "update",
|
||
form: Record<string, unknown>,
|
||
): Record<string, unknown> {
|
||
const out: Record<string, unknown> = {};
|
||
for (const f of fields) {
|
||
if (!fieldVisible(f, mode, form)) continue;
|
||
const raw = form[f.name];
|
||
if (f.type === "ids") {
|
||
const arr = parseIds(raw);
|
||
if (arr.length > 0 || f.required) out[f.name] = arr;
|
||
continue;
|
||
}
|
||
if (f.type === "number") {
|
||
if (raw === "" || raw === undefined || raw === null) {
|
||
if (f.required) out[f.name] = 0;
|
||
continue;
|
||
}
|
||
const n = Number(raw);
|
||
out[f.name] = Number.isFinite(n) ? n : 0;
|
||
continue;
|
||
}
|
||
if (f.type === "multiselect") {
|
||
const arr = Array.isArray(raw) ? raw.filter(Boolean) : [];
|
||
if (arr.length > 0 || f.required) out[f.name] = arr;
|
||
continue;
|
||
}
|
||
if (typeof raw === "string") {
|
||
const trimmed = raw.trim();
|
||
if (trimmed === "" && !f.required) continue;
|
||
out[f.name] = trimmed;
|
||
continue;
|
||
}
|
||
if (raw !== undefined && raw !== null) out[f.name] = raw;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// shallowEqualRow reports whether two row objects describe the same data.
|
||
// Scalars are compared with `Object.is`; arrays are compared element-wise
|
||
// (still by `Object.is`) since every entity returned by the admin API
|
||
// uses flat fields with at most one level of array (e.g. `squad_ids`).
|
||
// The comparison is intentionally conservative — when in doubt we return
|
||
// false and let the row re-render rather than risk masking a real change.
|
||
function shallowEqualRow(
|
||
a: Record<string, unknown>,
|
||
b: Record<string, unknown>,
|
||
): boolean {
|
||
if (a === b) return true;
|
||
const ak = Object.keys(a);
|
||
const bk = Object.keys(b);
|
||
if (ak.length !== bk.length) return false;
|
||
for (const k of ak) {
|
||
const av = a[k];
|
||
const bv = b[k];
|
||
if (Object.is(av, bv)) continue;
|
||
if (Array.isArray(av) && Array.isArray(bv)) {
|
||
if (av.length !== bv.length) return false;
|
||
let same = true;
|
||
for (let i = 0; i < av.length; i++) {
|
||
if (!Object.is(av[i], bv[i])) {
|
||
same = false;
|
||
break;
|
||
}
|
||
}
|
||
if (!same) return false;
|
||
continue;
|
||
}
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// reconcileRows preserves the previous row reference for any entity whose
|
||
// content is structurally identical to the freshly fetched one. The
|
||
// memoised <CrudRow> uses `prev.row === next.row` as part of its
|
||
// equality check, so handing back the *same* reference for unchanged rows
|
||
// means a refresh that returns identical data does no row-level re-render
|
||
// at all — the table simply stays put. When *no* row has changed (length
|
||
// equal, every reconciled entry === the previous entry) we hand back the
|
||
// previous array verbatim so React's setState bail-out kicks in and the
|
||
// parent doesn't re-render either.
|
||
function reconcileRows<T extends Record<string, unknown>>(
|
||
prev: T[],
|
||
next: T[],
|
||
idKey: keyof T,
|
||
): T[] {
|
||
if (prev.length === 0) return next;
|
||
const prevById = new Map<unknown, T>();
|
||
for (const r of prev) prevById.set(r[idKey] as unknown, r);
|
||
let allSame = prev.length === next.length;
|
||
const out: T[] = new Array(next.length);
|
||
for (let i = 0; i < next.length; i++) {
|
||
const row = next[i]!;
|
||
const old = prevById.get(row[idKey] as unknown);
|
||
if (old && shallowEqualRow(old, row)) {
|
||
out[i] = old;
|
||
if (allSame && prev[i] !== old) allSame = false;
|
||
} else {
|
||
out[i] = row;
|
||
allSame = false;
|
||
}
|
||
}
|
||
return allSame ? prev : out;
|
||
}
|
||
|
||
// useColumnWidths — Excel-style resizable column state.
|
||
//
|
||
// Returns the current width (in px) for each column key, plus a `setWidth`
|
||
// setter and a `startResize` helper that wires up a mousedown handler. The
|
||
// widths are persisted in localStorage under `${storageKey}` so they
|
||
// survive page reloads. Columns with no stored width fall back to the
|
||
// browser's auto distribution under `tableLayout: fixed`.
|
||
const COL_WIDTH_PREFIX = "sing-box-admin:colw:";
|
||
const MIN_COL_WIDTH = 60;
|
||
// Default width assigned to a data column that the user hasn't resized
|
||
// yet and whose label is shorter than this. Every data column always
|
||
// gets an explicit width (this default, or a wider measured natural
|
||
// minimum, or the user's stored value) so the only auto-sized cell in
|
||
// the row is the trailing "filler" cell that absorbs any leftover
|
||
// horizontal space — that's what stops resizing one column from
|
||
// rebalancing the others under `tableLayout: fixed`.
|
||
const DEFAULT_DATA_COL_WIDTH = 160;
|
||
function useColumnWidths(storageKey: string) {
|
||
const fullKey = COL_WIDTH_PREFIX + storageKey;
|
||
const [widths, setWidths] = useState<Record<string, number>>(() => {
|
||
try {
|
||
const raw = localStorage.getItem(fullKey);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === "object") return parsed;
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return {};
|
||
});
|
||
|
||
const persist = useCallback(
|
||
(next: Record<string, number>) => {
|
||
try {
|
||
localStorage.setItem(fullKey, JSON.stringify(next));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
},
|
||
[fullKey],
|
||
);
|
||
|
||
// Imperative setter used by the drag handler. We update the React state
|
||
// *and* mirror to localStorage from a single place so both stay in sync.
|
||
const setWidth = useCallback(
|
||
(key: string, w: number) => {
|
||
const clamped = Math.max(MIN_COL_WIDTH, Math.round(w));
|
||
setWidths((prev) => {
|
||
if (prev[key] === clamped) return prev;
|
||
const next = { ...prev, [key]: clamped };
|
||
persist(next);
|
||
return next;
|
||
});
|
||
},
|
||
[persist],
|
||
);
|
||
|
||
// Batched setter — used at resize-drag start to lock in the live widths
|
||
// of every previously-unstored column in a single state update. Without
|
||
// this lock-in, columns without a `stored` width auto-distribute under
|
||
// `tableLayout: fixed`, so growing the dragged column would also shrink
|
||
// every unstored neighbour by their share of the leftover (rubber-band
|
||
// resize). Storing each column's current rendered width turns the
|
||
// unstored neighbours into fixed-width columns for the duration of
|
||
// the drag, so only the dragged column changes width while the others
|
||
// stay pixel-stable. The cap from `getColumnMaxWidth` then keeps the
|
||
// total table width ≤ the visible viewport, so no column ever ends up
|
||
// tucked behind the sticky Actions cell.
|
||
const setManyWidths = useCallback(
|
||
(entries: Record<string, number>) => {
|
||
setWidths((prev) => {
|
||
let changed = false;
|
||
const next = { ...prev };
|
||
for (const k in entries) {
|
||
const clamped = Math.max(MIN_COL_WIDTH, Math.round(entries[k]));
|
||
if (prev[k] !== clamped) {
|
||
next[k] = clamped;
|
||
changed = true;
|
||
}
|
||
}
|
||
if (!changed) return prev;
|
||
persist(next);
|
||
return next;
|
||
});
|
||
},
|
||
[persist],
|
||
);
|
||
|
||
// Wipe every stored column width and drop the localStorage entry.
|
||
// After this the table reverts to the same first-render layout new
|
||
// users see: each data column falls back to `DEFAULT_DATA_COL_WIDTH`
|
||
// (or its measured natural minimum, whichever is larger), and the
|
||
// trailing filler cell soaks up the leftover space again. Used by
|
||
// the toolbar's "Reset column widths" button.
|
||
const resetWidths = useCallback(() => {
|
||
setWidths((prev) => (Object.keys(prev).length === 0 ? prev : {}));
|
||
try {
|
||
localStorage.removeItem(fullKey);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}, [fullKey]);
|
||
|
||
return { widths, setWidth, setManyWidths, resetWidths };
|
||
}
|
||
|
||
// usePersistedAppliedFilters — applied (submitted) filter values, persisted
|
||
// per-page in localStorage. `appliedFilters` is what's actually sent to the
|
||
// API; persisting it means a reload restores the user's last "Search"
|
||
// without forcing them to re-enter every filter. The draft state
|
||
// (`filterValues`) is left in component-local useState — half-typed inputs
|
||
// shouldn't survive a refresh.
|
||
const FILTERS_PREFIX = "sing-box-admin:filters:";
|
||
function usePersistedAppliedFilters(
|
||
storageKey: string,
|
||
): [Record<string, unknown>, (next: Record<string, unknown>) => void] {
|
||
const fullKey = FILTERS_PREFIX + storageKey;
|
||
const [values, setValuesRaw] = useState<Record<string, unknown>>(() => {
|
||
try {
|
||
const raw = localStorage.getItem(fullKey);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||
return parsed as Record<string, unknown>;
|
||
}
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return {};
|
||
});
|
||
const setValues = useCallback(
|
||
(next: Record<string, unknown>) => {
|
||
setValuesRaw(next);
|
||
try {
|
||
if (Object.keys(next).length === 0) {
|
||
localStorage.removeItem(fullKey);
|
||
} else {
|
||
localStorage.setItem(fullKey, JSON.stringify(next));
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
},
|
||
[fullKey],
|
||
);
|
||
return [values, setValues];
|
||
}
|
||
|
||
// usePersistedFiltersOpen — whether the filter panel is expanded. Mirrors
|
||
// the useState API (supports `(prev) => next` updates) so the existing
|
||
// `setShowFilters((s) => !s)` toggle keeps working unchanged.
|
||
//
|
||
// Mount strategy depends on whether this is the *very first* CrudPage
|
||
// instance mounted by the running SPA:
|
||
//
|
||
// - First app mount (cold reload, F5): the state initialises
|
||
// synchronously with the persisted value. Collapse renders with
|
||
// in=true on its first paint and the open animation is skipped —
|
||
// no "filter panel slides into view, table jumps down" jolt
|
||
// when the user reloads a page that had filters left open.
|
||
//
|
||
// - Subsequent mounts (navigating between admin routes via the
|
||
// sidebar): the state still starts at `false` and flips to the
|
||
// persisted value in a post-mount effect. MUI's <Collapse>
|
||
// observes a `false → true` transition on the second render and
|
||
// animates open, matching the user's expectation that switching
|
||
// to a page with persisted-open filters reads as a deliberate
|
||
// "filters appearing" rather than a static layout swap.
|
||
//
|
||
// The "is this the first app mount" decision is a module-level flag
|
||
// flipped once by the very first hook instance to mount. localStorage
|
||
// is read synchronously in both branches; the flag only changes the
|
||
// initial render's `in` value, not what's persisted.
|
||
const FILTERS_OPEN_PREFIX = "sing-box-admin:filters-open:";
|
||
let filtersFirstAppMount = true;
|
||
function readPersistedFiltersOpen(fullKey: string): boolean {
|
||
try {
|
||
return localStorage.getItem(fullKey) === "1";
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
function usePersistedFiltersOpen(
|
||
storageKey: string,
|
||
): [
|
||
boolean,
|
||
(next: boolean | ((prev: boolean) => boolean)) => void,
|
||
// `willAnimateOpen` — true iff the panel will transition from
|
||
// closed to open after first paint on this mount. Captured once
|
||
// at mount and never changes. CrudPage uses this to gate the
|
||
// very first reload() so the table doesn't pop rows in mid-
|
||
// animation when the user navigates between admin pages with
|
||
// persisted-open filters. False on cold mounts (the panel is
|
||
// committed already-open without animation) and on subsequent
|
||
// mounts where filters were left closed (no animation will play).
|
||
boolean,
|
||
] {
|
||
const fullKey = FILTERS_OPEN_PREFIX + storageKey;
|
||
// `useRef` so the "first ever hook instance" decision is stable
|
||
// across re-renders of *this* CrudPage (without it, a re-render
|
||
// before the post-mount effect runs would re-read
|
||
// `filtersFirstAppMount` *after* it had been flipped, and treat
|
||
// this same instance as a "subsequent mount").
|
||
const isFirstMountRef = useRef(filtersFirstAppMount);
|
||
const [open, setOpenRaw] = useState<boolean>(() =>
|
||
isFirstMountRef.current ? readPersistedFiltersOpen(fullKey) : false,
|
||
);
|
||
// Captured once at mount alongside the initial state so cold vs
|
||
// animated branches stay in sync (we evaluate the same flag and
|
||
// the same persisted value, so a later localStorage change can't
|
||
// make the two disagree on this mount).
|
||
const willAnimateOpenRef = useRef(
|
||
!isFirstMountRef.current && readPersistedFiltersOpen(fullKey),
|
||
);
|
||
useEffect(() => {
|
||
if (isFirstMountRef.current) {
|
||
// The first-ever CrudPage in the SPA already committed with the
|
||
// persisted value applied — no second-render flip needed, and
|
||
// no open animation should play. Just record that we're done
|
||
// with the cold-load path so any later route change (which
|
||
// mounts a fresh CrudPage instance) takes the animated branch.
|
||
filtersFirstAppMount = false;
|
||
return;
|
||
}
|
||
if (readPersistedFiltersOpen(fullKey)) setOpenRaw(true);
|
||
}, [fullKey]);
|
||
const setOpen = useCallback(
|
||
(next: boolean | ((prev: boolean) => boolean)) => {
|
||
setOpenRaw((prev) => {
|
||
const resolved = typeof next === "function" ? next(prev) : next;
|
||
try {
|
||
localStorage.setItem(fullKey, resolved ? "1" : "0");
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return resolved;
|
||
});
|
||
},
|
||
[fullKey],
|
||
);
|
||
return [open, setOpen, willAnimateOpenRef.current];
|
||
}
|
||
|
||
// usePersistedPageSize — TablePagination rows-per-page, persisted per-page
|
||
// in localStorage so the user's choice (10/25/50/100) survives reloads.
|
||
// Falls back to `defaultSize` when nothing is stored or the stored value
|
||
// is corrupted.
|
||
const PAGE_SIZE_PREFIX = "sing-box-admin:pagesize:";
|
||
function usePersistedPageSize(
|
||
storageKey: string,
|
||
defaultSize: number,
|
||
): [number, (next: number) => void] {
|
||
const fullKey = PAGE_SIZE_PREFIX + storageKey;
|
||
const [size, setSizeRaw] = useState<number>(() => {
|
||
try {
|
||
const raw = localStorage.getItem(fullKey);
|
||
if (raw) {
|
||
const n = Number(raw);
|
||
if (Number.isFinite(n) && n > 0) return n;
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return defaultSize;
|
||
});
|
||
const setSize = useCallback(
|
||
(next: number) => {
|
||
setSizeRaw(next);
|
||
try {
|
||
localStorage.setItem(fullKey, String(next));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
},
|
||
[fullKey],
|
||
);
|
||
return [size, setSize];
|
||
}
|
||
|
||
// usePersistedSort — active sort column + direction, persisted per-page in
|
||
// localStorage so the user's chosen ordering survives reloads and tab
|
||
// switches between admin pages. Both slices are stored together as a
|
||
// single JSON blob (`{ field, dir }`) so the two-stage "set field, then
|
||
// set dir" updates in `handleSort` and the mobile sort dropdown can never
|
||
// land in a half-written intermediate state on disk. Setters mirror the
|
||
// `useState` API (each accepts either a value or an updater) so the
|
||
// existing `setSortDir((d) => …)` toggle keeps working unchanged.
|
||
//
|
||
// Falls back to `defaultSort` (or `null` / `"asc"`) only when nothing is
|
||
// stored — once the user has touched the sort, an explicit "no sort"
|
||
// state (`field === null`) is preserved across reloads too.
|
||
const SORT_PREFIX = "sing-box-admin:sort:";
|
||
type SortDir = "asc" | "desc";
|
||
function usePersistedSort(
|
||
storageKey: string,
|
||
defaultSort: { field: string; dir: SortDir } | undefined,
|
||
): [
|
||
string | null,
|
||
(next: string | null | ((prev: string | null) => string | null)) => void,
|
||
SortDir,
|
||
(next: SortDir | ((prev: SortDir) => SortDir)) => void,
|
||
] {
|
||
const fullKey = SORT_PREFIX + storageKey;
|
||
const [state, setState] = useState<{ field: string | null; dir: SortDir }>(
|
||
() => {
|
||
try {
|
||
const raw = localStorage.getItem(fullKey);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||
const field =
|
||
typeof parsed.field === "string" || parsed.field === null
|
||
? parsed.field
|
||
: null;
|
||
const dir: SortDir = parsed.dir === "desc" ? "desc" : "asc";
|
||
return { field, dir };
|
||
}
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return {
|
||
field: defaultSort?.field ?? null,
|
||
dir: defaultSort?.dir ?? "asc",
|
||
};
|
||
},
|
||
);
|
||
|
||
const persist = useCallback(
|
||
(next: { field: string | null; dir: SortDir }) => {
|
||
try {
|
||
localStorage.setItem(fullKey, JSON.stringify(next));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
},
|
||
[fullKey],
|
||
);
|
||
|
||
const setField = useCallback(
|
||
(next: string | null | ((prev: string | null) => string | null)) => {
|
||
setState((prev) => {
|
||
const resolved = typeof next === "function" ? next(prev.field) : next;
|
||
if (resolved === prev.field) return prev;
|
||
const updated = { ...prev, field: resolved };
|
||
persist(updated);
|
||
return updated;
|
||
});
|
||
},
|
||
[persist],
|
||
);
|
||
|
||
const setDir = useCallback(
|
||
(next: SortDir | ((prev: SortDir) => SortDir)) => {
|
||
setState((prev) => {
|
||
const resolved = typeof next === "function" ? next(prev.dir) : next;
|
||
if (resolved === prev.dir) return prev;
|
||
const updated = { ...prev, dir: resolved };
|
||
persist(updated);
|
||
return updated;
|
||
});
|
||
},
|
||
[persist],
|
||
);
|
||
|
||
return [state.field, setField, state.dir, setDir];
|
||
}
|
||
|
||
// ResizeHandle is the thin invisible bar that lives at the right edge of
|
||
// each resizable header cell. Mousedown captures the cursor + the header
|
||
// cell's current width, then mousemove updates the column width on every
|
||
// frame as the cursor drags. The handle paints a faint accent-coloured
|
||
// guide while dragging so the user can see exactly which column they're
|
||
// adjusting.
|
||
function ResizeHandle({
|
||
columnKey,
|
||
getCurrentWidth,
|
||
setWidth,
|
||
getMinWidth,
|
||
getMaxWidth,
|
||
onResizeStart,
|
||
clipToCell = false,
|
||
}: {
|
||
columnKey: string;
|
||
getCurrentWidth: () => number;
|
||
setWidth: (key: string, w: number) => void;
|
||
// Per-column lower bound, measured from the actual header content so the
|
||
// column can never be dragged narrower than its label + sort icon + cell
|
||
// padding (otherwise the contents would overflow into the neighbouring
|
||
// column). Falls back to the global `MIN_COL_WIDTH` if not provided.
|
||
getMinWidth?: () => number;
|
||
// Per-column upper bound. CrudPage passes a getter that keeps the sum
|
||
// of all column widths ≤ the visible scroll viewport, so a column's
|
||
// right edge can never travel past the sticky Actions column. Without
|
||
// it the drag is unconstrained on the upper end.
|
||
getMaxWidth?: () => number;
|
||
// Fires once at the start of a drag, before `getCurrentWidth` is read
|
||
// and before any mousemove listener is attached. CrudPage uses this to
|
||
// lock in the live widths of every previously-unstored data column so
|
||
// dragging one column doesn't rubber-band the unstored neighbours by
|
||
// their share of the auto-distributed leftover; instead, only the
|
||
// dragged column changes width and the cap from `getColumnMaxWidth`
|
||
// keeps the total ≤ the visible viewport.
|
||
onResizeStart?: () => void;
|
||
// When true, the grab strip is clipped to the cell's own right edge
|
||
// instead of overflowing 7 px into the neighbouring cell. Used for
|
||
// the rightmost data column so the grab strip doesn't extend into
|
||
// the sticky Actions column — its left edge should not feel
|
||
// draggable.
|
||
clipToCell?: boolean;
|
||
}) {
|
||
const [active, setActive] = useState(false);
|
||
|
||
const onMouseDown = useCallback(
|
||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
// Run before reading `startW` so any width lock-in performed by
|
||
// the parent (promoting auto-distributed columns to fixed widths)
|
||
// is reflected in the layout the drag math anchors to. The DOM
|
||
// read inside `getCurrentWidth` happens after this; even though
|
||
// React hasn't re-rendered yet, the cell's bounding box is
|
||
// unchanged (we lock in the *current* live widths), so the drag
|
||
// anchor stays pixel-stable.
|
||
onResizeStart?.();
|
||
const startX = e.clientX;
|
||
const startW = getCurrentWidth();
|
||
setActive(true);
|
||
const prevCursor = document.body.style.cursor;
|
||
const prevSelect = document.body.style.userSelect;
|
||
document.body.style.cursor = "col-resize";
|
||
document.body.style.userSelect = "none";
|
||
const onMove = (ev: MouseEvent) => {
|
||
const target = startW + (ev.clientX - startX);
|
||
const min = getMinWidth ? getMinWidth() : MIN_COL_WIDTH;
|
||
const max = getMaxWidth ? getMaxWidth() : Number.POSITIVE_INFINITY;
|
||
// Order matters: cap by max first, then floor by min, so that
|
||
// when min > max (an over-tight container) the lower bound
|
||
// wins and the column at least stays legible.
|
||
setWidth(columnKey, Math.max(min, Math.min(max, target)));
|
||
};
|
||
const onUp = () => {
|
||
setActive(false);
|
||
document.body.style.cursor = prevCursor;
|
||
document.body.style.userSelect = prevSelect;
|
||
document.removeEventListener("mousemove", onMove);
|
||
document.removeEventListener("mouseup", onUp);
|
||
};
|
||
document.addEventListener("mousemove", onMove);
|
||
document.addEventListener("mouseup", onUp);
|
||
},
|
||
[columnKey, getCurrentWidth, setWidth, getMinWidth, getMaxWidth, onResizeStart],
|
||
);
|
||
|
||
// Double-click "resets" the column to the smallest size that still fits
|
||
// its header content — same lower bound the drag handler enforces. With
|
||
// a tighter natural minimum than the previous flat 60 px floor, the
|
||
// reset never produces a column whose label spills into a neighbour.
|
||
return (
|
||
<Box
|
||
onMouseDown={onMouseDown}
|
||
onDoubleClick={(e) => {
|
||
e.stopPropagation();
|
||
const min = getMinWidth ? getMinWidth() : MIN_COL_WIDTH;
|
||
setWidth(columnKey, min);
|
||
}}
|
||
sx={(t) => ({
|
||
position: "absolute",
|
||
top: 0,
|
||
// Grab area is by default centred on the cell's right border
|
||
// (right: -7, width: 14 → 7 px on each side of the boundary).
|
||
// Wider than the previous 6 px strip so the column edge is
|
||
// easy to hit without pixel-perfect aim, while still narrow
|
||
// enough that it doesn't overlap any reasonable header
|
||
// content.
|
||
//
|
||
// For the rightmost data column we instead clip the strip to
|
||
// the cell's own right edge (right: 0, width: 7). That keeps
|
||
// the boundary against the sticky Actions cell completely
|
||
// dead — no col-resize cursor, no draggable strip — while
|
||
// still giving the user 7 px to grab on the column's own
|
||
// side of the boundary.
|
||
right: clipToCell ? 0 : -7,
|
||
bottom: 0,
|
||
width: clipToCell ? 7 : 14,
|
||
cursor: "col-resize",
|
||
zIndex: 2,
|
||
// The thin coloured guide that appears on hover and stays visible
|
||
// while the user is actively dragging. Centred inside the grab
|
||
// strip: (14 − 2) / 2 = 6 px from the left edge — or 5 px in
|
||
// the clipped 7 px variant so the guide still sits flush with
|
||
// the cell's right border.
|
||
"&::after": {
|
||
content: '""',
|
||
position: "absolute",
|
||
top: 8,
|
||
bottom: 8,
|
||
left: clipToCell ? 5 : 6,
|
||
width: 2,
|
||
borderRadius: 1,
|
||
backgroundColor: active ? "var(--sb-accent)" : "transparent",
|
||
transition: "background-color 0.12s ease",
|
||
},
|
||
"&:hover::after": {
|
||
backgroundColor: active
|
||
? "var(--sb-accent)"
|
||
: t.palette.action.selected,
|
||
},
|
||
})}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function CrudPage<TEntity, TCreate, TUpdate>(props: { config: CrudConfig<TEntity, TCreate, TUpdate> }) {
|
||
const { config } = props;
|
||
// Responsive bits in this component (filter-cell widths, etc.) are
|
||
// handled inline via `sx` breakpoint objects. Mobile-only full-screen
|
||
// for the create/edit dialog is decided inside `CrudDialog` itself so
|
||
// we don't have to thread a flag through every prop.
|
||
//
|
||
// Below `sm` (≤ 600 px) the data is rendered as a vertical list of
|
||
// cards instead of a horizontally-scrolling table. On a phone the
|
||
// table's narrow per-column widths make every value clip and force
|
||
// the user to swipe sideways through the column set just to read a
|
||
// single row — cards display every field of one row top-to-bottom
|
||
// with a label/value layout, which reads naturally on touch and
|
||
// keeps the entire row visible at once.
|
||
const tableTheme = useTheme();
|
||
const isMobile = useMediaQuery(tableTheme.breakpoints.down("sm"));
|
||
const notify = useNotify();
|
||
// entityLabel is the singular form of `config.title` ("Squads" → "Squad")
|
||
// used in user-visible toast messages like "Squad created" / "3 Users
|
||
// deleted". The slice handles the common plural-with-trailing-s pattern
|
||
// every page in the admin uses today; pages whose title doesn't end in
|
||
// "s" fall through unchanged.
|
||
const entityLabel = useMemo(() => {
|
||
const t = config.title;
|
||
return t.endsWith("s") ? t.slice(0, -1) : t;
|
||
}, [config.title]);
|
||
const [rows, setRows] = useState<TEntity[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
// `loadingVisible` is the user-facing loading flag — it lags behind
|
||
// `loading` by ~180 ms so refreshes that finish quickly (e.g. on a
|
||
// local network, where the round-trip is well under that threshold)
|
||
// don't flash the LinearProgress or dim the table at all. The
|
||
// refresh-icon spinner uses `loading` directly so the click still
|
||
// gets immediate feedback; only the heavier visual treatment of the
|
||
// table itself is deferred.
|
||
const [loadingVisible, setLoadingVisible] = useState(false);
|
||
useEffect(() => {
|
||
if (!loading) {
|
||
setLoadingVisible(false);
|
||
return;
|
||
}
|
||
const id = window.setTimeout(() => setLoadingVisible(true), 180);
|
||
return () => window.clearTimeout(id);
|
||
}, [loading]);
|
||
const [refreshSpinning, setRefreshSpinning] = useState(false);
|
||
// `paginating` flips to `true` the moment the user changes page /
|
||
// page size / sort / filters and stays true until the new data
|
||
// arrives. We use it for two things:
|
||
//
|
||
// (a) Fade just the row body to opacity 0 — desktop TableBody and
|
||
// mobile row-cards Stack — so the previous page's records
|
||
// don't visibly linger under the in-flight request. The
|
||
// column headers (desktop TableHead) and the mobile
|
||
// select-all + sort toolbar deliberately stay at full
|
||
// opacity, so the structure of the table remains legible
|
||
// while the data cells disappear.
|
||
//
|
||
// Rows stay *mounted* during this fade — clearing them would
|
||
// collapse the table card to the bare empty-state height,
|
||
// which reads as a sudden snap; keeping them and just hiding
|
||
// them with opacity preserves the card's current height all
|
||
// the way through the round-trip. Once `reload` lands the
|
||
// new rows in `setRows`, the same gate fades the body back
|
||
// to opacity 1, so the new entries cross-fade into the slot
|
||
// the old ones were in.
|
||
//
|
||
// (b) Suppress the "No data yet" empty-state placeholder so it
|
||
// doesn't flash between the moment the rows are hidden and
|
||
// the moment the new ones land — relevant when the previous
|
||
// page itself was empty and the user filtered / paginated
|
||
// to a different empty (or pending) result.
|
||
const [paginating, setPaginating] = useState(false);
|
||
|
||
// configRef holds the latest config without forcing `reload` to re-fire
|
||
// when the parent's useMemo reidentifies it. Pages that do
|
||
//
|
||
// const squads = useSquads(api);
|
||
// const config = useMemo(() => ({ ... }), [api, squads]);
|
||
//
|
||
// (Nodes / Users / *Limiters) get a *new* config object the moment the
|
||
// /squads request resolves, even though `list` / `count` / `filters` are
|
||
// structurally identical to the ones from the previous render. Reading the
|
||
// CRUD callbacks via `configRef.current` inside the imperative reload
|
||
// means a content-equivalent config swap doesn't trigger a second list +
|
||
// count round-trip — fixing the "table loads twice" symptom on every
|
||
// squad-aware page.
|
||
//
|
||
// The declarative paths (column rendering, filter chips, dialog fields)
|
||
// keep reading `config` directly so they pick up the new render functions
|
||
// / option lists on the next paint, which is what makes squad name chips
|
||
// appear once /squads resolves.
|
||
const configRef = useRef(config);
|
||
configRef.current = config;
|
||
|
||
// Filter state. Text/select entries are strings; datetime-range entries
|
||
// are `{ start?: string; end?: string }` objects holding the raw value of
|
||
// <input type="datetime-local" />.
|
||
//
|
||
// `filterValues` is the live "draft" — whatever is currently typed in the
|
||
// panel. `appliedFilters` is what was actually submitted via Search. The
|
||
// table re-fetches only when `appliedFilters` changes, so typing into a
|
||
// text filter doesn't fire one request per keystroke.
|
||
//
|
||
// `filterSpecs` is memoised by the JSON of `config.filters` rather than by
|
||
// its array reference. Pages declare `filters: [...]` inline inside their
|
||
// `useMemo` factory, so the array literal is recreated whenever the
|
||
// factory re-runs (e.g. when a `useSquads` catalog finishes loading). The
|
||
// contents are unchanged, but a new reference would ripple through every
|
||
// dep array that lists `filterSpecs` — including `reload`'s — and
|
||
// re-fetch the table for no reason. JSON-stringifying the spec keeps the
|
||
// memo stable across content-equivalent reidentifications. Filter specs
|
||
// are tiny (`name`/`label`/`type` plus a few flags), so the stringify
|
||
// cost is negligible.
|
||
const filtersJSON = JSON.stringify(config.filters ?? []);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
const filterSpecs = useMemo(() => config.filters ?? [], [filtersJSON]);
|
||
// `appliedFilters` is persisted per-page in localStorage so the user's
|
||
// last search survives reloads. The draft (`filterValues`) starts in
|
||
// sync with the persisted applied set so the inputs render filled with
|
||
// whatever was previously applied; subsequent typing stays
|
||
// component-local until the user presses "Search" (or "Reset").
|
||
const [appliedFilters, setAppliedFilters] = usePersistedAppliedFilters(
|
||
config.title,
|
||
);
|
||
const [filterValues, setFilterValues] = useState<Record<string, unknown>>(
|
||
() => appliedFilters,
|
||
);
|
||
const [showFilters, setShowFilters, filtersWillAnimateOpen] =
|
||
usePersistedFiltersOpen(config.title);
|
||
// `filtersFirstPaintReady` gates the very first reload() so the
|
||
// table starts loading rows AFTER the filter panel has settled
|
||
// into its initial state on this mount. Without this gate the
|
||
// rows pop in before / during the panel's open animation when
|
||
// the user navigates from the sidebar to a page with persisted-
|
||
// open filters (subsequent CrudPage mount, panel transitions
|
||
// from closed → open over ~180 ms).
|
||
//
|
||
// Initial value is true unless this mount is going to animate
|
||
// the panel open — i.e. cold mounts, mounts with no filter
|
||
// specs, and mounts where the panel was left closed are all
|
||
// ready immediately. Subsequent flips happen via the Collapse's
|
||
// `onEntered` callback (animation finished) or via a small
|
||
// fallback timeout if the event was swallowed (e.g. the user
|
||
// toggled the panel mid-animation).
|
||
const [filtersFirstPaintReady, setFiltersFirstPaintReady] = useState(
|
||
() => (config.filters?.length ?? 0) === 0 || !filtersWillAnimateOpen,
|
||
);
|
||
const activeFilterCount = useMemo(
|
||
() => countActiveFilters(filterSpecs, appliedFilters),
|
||
[filterSpecs, appliedFilters],
|
||
);
|
||
const draftFilterCount = useMemo(
|
||
() => countActiveFilters(filterSpecs, filterValues),
|
||
[filterSpecs, filterValues],
|
||
);
|
||
const filtersDirty = useMemo(
|
||
() => !filterValuesEqual(filterSpecs, filterValues, appliedFilters),
|
||
[filterSpecs, filterValues, appliedFilters],
|
||
);
|
||
|
||
// Pagination state. `page` is 0-indexed (MUI's TablePagination contract).
|
||
// `total` is null until the count endpoint replies — when it's null the
|
||
// pagination bar shows "1–N of more than N" so the user can still navigate.
|
||
// `pageSize` is persisted per-page in localStorage so the user's chosen
|
||
// rows-per-page survives reloads.
|
||
const [page, setPage] = useState(0);
|
||
const [pageSize, setPageSize] = usePersistedPageSize(
|
||
config.title,
|
||
config.defaultPageSize ?? 10,
|
||
);
|
||
const [total, setTotal] = useState<number | null>(null);
|
||
|
||
// Sort state. `field` mirrors the column key used by the API; the
|
||
// direction is "asc" / "desc" → translated to `sort_asc` / `sort_desc`
|
||
// query params, matching service/admin_panel/tables/*.go.
|
||
//
|
||
// Persisted per-page in localStorage (keyed off `config.title`, same
|
||
// namespace as page size / filters / column widths) so switching
|
||
// between admin tabs — or reloading the page — keeps whatever
|
||
// column + direction the user last picked, including a deliberate
|
||
// "no sort" choice (`sortField === null` after a third header click).
|
||
const [sortField, setSortField, sortDir, setSortDir] = usePersistedSort(
|
||
config.title,
|
||
config.defaultSort,
|
||
);
|
||
|
||
// Each reload picks up a unique id; a response is only allowed to flip
|
||
// state if its id still matches the latest. Without this guard a slow
|
||
// prior reload could land *after* a newer one and overwrite the freshly
|
||
// filtered rows with stale data.
|
||
const reqRef = useRef(0);
|
||
// Flips to `true` the first time a reload finishes (success or error).
|
||
// The empty-state placeholder is rendered (and reserves its full
|
||
// height) from mount onwards so the card never collapses to a
|
||
// header-only strip while the first request is in flight; this flag
|
||
// controls only its `visibility`, so the "No data yet" copy and CTA
|
||
// never flash before we know whether the API actually has rows.
|
||
const [hasFetched, setHasFetched] = useState(false);
|
||
|
||
// Excel-style resizable column widths, persisted per page (keyed off the
|
||
// page title). `headerCellRefs` tracks the live `<th>` element for each
|
||
// column so the ResizeHandle can read the column's *actual* current
|
||
// pixel width when the drag starts — that way the auto-distributed
|
||
// widths from `tableLayout: fixed` on first render are honoured before
|
||
// the user has touched anything.
|
||
const {
|
||
widths: columnWidths,
|
||
setWidth: setColumnWidth,
|
||
setManyWidths: setManyColumnWidths,
|
||
resetWidths: resetColumnWidths,
|
||
} = useColumnWidths(config.title);
|
||
// True iff the user has resized at least one column away from its
|
||
// default width — i.e. the localStorage entry would have a non-empty
|
||
// payload. Drives the conditional render of the toolbar's "Reset
|
||
// column widths" button so it only surfaces when there's actually
|
||
// something to reset.
|
||
const hasCustomColumnWidths = Object.keys(columnWidths).length > 0;
|
||
const headerCellRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
||
// Inner ref to the inline-flex span that wraps each header's visible
|
||
// content (label + sort caret). Measured to derive the per-column
|
||
// natural minimum width — see `columnNaturalMins` below.
|
||
const headerLabelRefs = useRef<Record<string, HTMLElement | null>>({});
|
||
// Ref to the horizontally-scrolling container around the table. Used
|
||
// to clamp column resize so a column's right edge can never travel
|
||
// past the sticky Actions column — i.e. the total table width never
|
||
// exceeds the visible scroll viewport width. Without this clamp the
|
||
// user could grow a column wider than the viewport and end up with
|
||
// the rightmost data columns hidden underneath the pinned Actions.
|
||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||
const [tableViewportWidth, setTableViewportWidth] = useState<number | null>(
|
||
null,
|
||
);
|
||
useEffect(() => {
|
||
const el = scrollContainerRef.current;
|
||
if (!el || typeof ResizeObserver === "undefined") return;
|
||
const ro = new ResizeObserver(() => {
|
||
setTableViewportWidth(el.clientWidth);
|
||
});
|
||
ro.observe(el);
|
||
setTableViewportWidth(el.clientWidth);
|
||
return () => ro.disconnect();
|
||
}, []);
|
||
const getColumnLiveWidth = useCallback(
|
||
(key: string): number => {
|
||
const stored = columnWidths[key];
|
||
if (stored != null) return stored;
|
||
const el = headerCellRefs.current[key];
|
||
return el ? el.getBoundingClientRect().width : DEFAULT_DATA_COL_WIDTH;
|
||
},
|
||
[columnWidths],
|
||
);
|
||
|
||
// Promote every previously-unstored data column to a fixed width equal
|
||
// to its current rendered width. Called once at the start of every
|
||
// resize drag so the unstored neighbours don't rubber-band along with
|
||
// the dragged column. Under `tableLayout: fixed`, columns without an
|
||
// explicit width share the leftover horizontal space equally; without
|
||
// this lock-in, growing the dragged column would shrink each unstored
|
||
// neighbour by their share of the leftover instead of leaving them
|
||
// alone. We snapshot live widths (not natural mins) so the visual
|
||
// layout is byte-identical the instant the drag starts — the user
|
||
// sees no jump.
|
||
const lockInUnstoredColumnWidths = useCallback(() => {
|
||
const entries: Record<string, number> = {};
|
||
for (const c of config.columns) {
|
||
const key = String(c.key);
|
||
if (columnWidths[key] != null) continue;
|
||
const el = headerCellRefs.current[key];
|
||
if (!el) continue;
|
||
entries[key] = el.getBoundingClientRect().width;
|
||
}
|
||
if (Object.keys(entries).length > 0) setManyColumnWidths(entries);
|
||
}, [config.columns, columnWidths, setManyColumnWidths]);
|
||
|
||
// columnNaturalMins is the smallest cell width that still fully shows a
|
||
// column's header content (label text + sort caret) plus the cell's
|
||
// horizontal padding. We measure the inner inline-flex wrapper after
|
||
// each render — that node sizes itself to its content regardless of
|
||
// the cell's enforced width, so it stays accurate even when the user
|
||
// has resized the column smaller than its content.
|
||
//
|
||
// The state form (rather than a ref) is on purpose: when the natural
|
||
// minimum changes (different label, font-load), we want React to
|
||
// re-render so the cell's `width` is recomputed via the
|
||
// `Math.max(stored, naturalMin)` rule below, and the visible column
|
||
// can never be narrower than its content.
|
||
const [columnNaturalMins, setColumnNaturalMins] = useState<
|
||
Record<string, number>
|
||
>({});
|
||
// Buffer added on top of the measured label width — accounts for the
|
||
// TableCell's left + right padding (16 px each in the size="small"
|
||
// variant) plus a small safety margin so the resize handle's grab strip
|
||
// never overlaps the last glyph of the label.
|
||
const HEADER_PADDING_BUFFER = 32 + 8;
|
||
useLayoutEffect(() => {
|
||
const next: Record<string, number> = {};
|
||
for (const c of config.columns) {
|
||
const key = String(c.key);
|
||
const el = headerLabelRefs.current[key];
|
||
if (!el) continue;
|
||
next[key] =
|
||
Math.ceil(el.getBoundingClientRect().width) + HEADER_PADDING_BUFFER;
|
||
}
|
||
setColumnNaturalMins((prev) => {
|
||
let same = Object.keys(next).length === Object.keys(prev).length;
|
||
if (same) {
|
||
for (const k in next) {
|
||
if (prev[k] !== next[k]) {
|
||
same = false;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return same ? prev : next;
|
||
});
|
||
});
|
||
|
||
const getColumnNaturalMin = useCallback(
|
||
(key: string): number => {
|
||
const measured = columnNaturalMins[key];
|
||
return measured != null ? Math.max(MIN_COL_WIDTH, measured) : MIN_COL_WIDTH;
|
||
},
|
||
[columnNaturalMins],
|
||
);
|
||
|
||
// tableMinWidth is the table's lower-bound width used to prevent column
|
||
// overlap when the user resizes columns to a sum that exceeds the
|
||
// container's width. With `tableLayout: fixed` and the default
|
||
// `width: 100%`, a column that the user has dragged wider would
|
||
// otherwise force the *other* columns to visually compress and overlap
|
||
// their content (because the table itself can't grow past the
|
||
// container). Setting `minWidth` to the actual sum of column widths
|
||
// forces the table to grow when the columns no longer fit, which in
|
||
// turn lets the surrounding `TableContainer` (which has
|
||
// `overflow-x: auto`) provide a natural horizontal scrollbar.
|
||
//
|
||
// Each column contributes `max(stored, naturalMin)` so the lower bound
|
||
// also covers cases where a previously-stored width is now narrower
|
||
// than the column's measured content (e.g. the label text grew, or the
|
||
// localStorage value predates this fix).
|
||
const tableMinWidth = useMemo(() => {
|
||
// checkbox column + actions column. The actions column starts at
|
||
// 92 px (Edit + Delete) and grows by ~32 px per extra rowAction so
|
||
// the sticky cell doesn't have to scroll its own children when a
|
||
// page configures additional buttons (e.g. the "reset traffic"
|
||
// action on the Traffic Limiters page).
|
||
let sum = 72 + 92 + (config.rowActions?.length ?? 0) * 32;
|
||
for (const c of config.columns) {
|
||
const key = String(c.key);
|
||
const stored = columnWidths[key];
|
||
const naturalMin = getColumnNaturalMin(key);
|
||
sum += stored != null
|
||
? Math.max(stored, naturalMin)
|
||
: Math.max(naturalMin, DEFAULT_DATA_COL_WIDTH);
|
||
}
|
||
return sum;
|
||
}, [config.columns, columnWidths, getColumnNaturalMin]);
|
||
|
||
// getColumnMaxWidth — upper bound for a single column's width during
|
||
// a resize drag. Excel-style: the only cap is the column's own
|
||
// natural minimum (which is really a lower-bound, applied via
|
||
// `Math.max(min, target)` in the drag handler — exposed here so the
|
||
// ResizeHandle's max never falls below it for over-tight viewports).
|
||
//
|
||
// We deliberately do NOT cap by the visible scroll viewport: the
|
||
// surrounding `TableContainer` is `overflow-x: auto`, so dragging a
|
||
// column wider than the viewport just makes the table overflow and
|
||
// scroll horizontally. The sticky Actions column stays pinned to
|
||
// the right edge of the viewport regardless of how wide the data
|
||
// columns get, which is the same behaviour spreadsheet users
|
||
// expect.
|
||
const getColumnMaxWidth = useCallback(
|
||
(_key: string): number => {
|
||
// No upper cap: the user can pull a column as wide as they like;
|
||
// the table simply grows past the viewport and the surrounding
|
||
// TableContainer scrolls horizontally.
|
||
return Number.POSITIVE_INFINITY;
|
||
},
|
||
[],
|
||
);
|
||
const reload = useCallback(async () => {
|
||
const reqId = ++reqRef.current;
|
||
setLoading(true);
|
||
try {
|
||
// Read every config field through the ref so a content-equivalent
|
||
// config swap (see `configRef` above) doesn't reach this callback's
|
||
// dep array and trigger a duplicate fetch.
|
||
const cfg = configRef.current;
|
||
const filterQuery = buildFilterQuery(filterSpecs, appliedFilters);
|
||
// Only the list endpoint cares about pagination + sort; count is
|
||
// computed against the same filters but ignores those params.
|
||
const listQuery: Listable = {
|
||
...filterQuery,
|
||
offset: page * pageSize,
|
||
limit: pageSize,
|
||
};
|
||
if (sortField) {
|
||
if (sortDir === "asc") listQuery.sort_asc = sortField;
|
||
else listQuery.sort_desc = sortField;
|
||
}
|
||
const [rowsResult, countResult] = await Promise.all([
|
||
cfg.list(listQuery),
|
||
cfg.count ? cfg.count(filterQuery) : Promise.resolve<number | null>(null),
|
||
]);
|
||
if (reqRef.current !== reqId) return;
|
||
// Notify observers (e.g. useSquadCatalog) with the new row set
|
||
// *before* publishing it so they can fetch the auxiliary data
|
||
// those rows reference (squad names, …). When the observer
|
||
// returns a promise we await it, which is what keeps the table
|
||
// from briefly rendering with raw ids before the friendly chip
|
||
// labels arrive — once setRows fires, the supporting catalogs
|
||
// are already populated and the first paint shows the final
|
||
// names. `onRowsChange` is read through configRef — not the
|
||
// `reload` dep array — so a new callback identity from the
|
||
// parent's `useMemo` doesn't retrigger the fetch.
|
||
if (cfg.onRowsChange) {
|
||
try {
|
||
await cfg.onRowsChange(rowsResult);
|
||
} catch {
|
||
/* best-effort: a misbehaving observer must not break the table */
|
||
}
|
||
if (reqRef.current !== reqId) return;
|
||
}
|
||
// Reconcile by primary key so refreshes that return identical data
|
||
// re-use the previous row references — the memoised <CrudRow>
|
||
// skips its render path when `prev.row === next.row`, which is
|
||
// what keeps the table from visibly redrawing on a no-op refresh.
|
||
// When every row is unchanged we even hand back the previous
|
||
// array so React's setState bail-out short-circuits the parent
|
||
// re-render entirely.
|
||
setRows((prev) =>
|
||
reconcileRows(
|
||
prev as unknown as Record<string, unknown>[],
|
||
rowsResult as unknown as Record<string, unknown>[],
|
||
String(cfg.idKey),
|
||
) as unknown as TEntity[],
|
||
);
|
||
setTotal(countResult);
|
||
} catch (e) {
|
||
if (reqRef.current !== reqId) return;
|
||
// Reload errors are surfaced exclusively through the global toast
|
||
// stack — no inline Alert in the page chrome. The empty-state
|
||
// placeholder still renders below (the table just stays empty)
|
||
// so the user has both the toast and the visual cue that no rows
|
||
// came back. `notifyApiError` picks a useful description for the
|
||
// exception class (connection vs. HTTP vs. unauthorized).
|
||
notifyApiError(notify, `Failed to load ${configRef.current.title}`, e);
|
||
} finally {
|
||
if (reqRef.current === reqId) {
|
||
setLoading(false);
|
||
setHasFetched(true);
|
||
// Drop the `paginating` flag the moment the latest in-flight
|
||
// request resolves — success or failure. The empty-state
|
||
// placeholder reappears (if `rows` is still empty) and the
|
||
// user's pagination/sort/filter change is fully landed.
|
||
// Stale reloads (reqId mismatch above) leave it alone so a
|
||
// newer pending pagination keeps the placeholder hidden.
|
||
setPaginating(false);
|
||
}
|
||
}
|
||
}, [filterSpecs, appliedFilters, page, pageSize, sortField, sortDir, notify]);
|
||
|
||
useEffect(() => {
|
||
if (!filtersFirstPaintReady) return;
|
||
void reload();
|
||
}, [reload, filtersFirstPaintReady]);
|
||
|
||
// Safety net for the animated-open path: the Collapse's `onEntered`
|
||
// is what normally flips `filtersFirstPaintReady` to true after the
|
||
// panel finishes its ~180 ms open transition. If for any reason that
|
||
// event doesn't fire — e.g. the user toggled the panel closed
|
||
// mid-animation, or the Collapse unmounted before the transitionend
|
||
// landed — fall back to a short timeout so the table doesn't sit
|
||
// empty forever waiting for an event that already came and went.
|
||
// This effect is a no-op for every mount path that initialises
|
||
// `filtersFirstPaintReady` to true (cold mounts, no-filter pages,
|
||
// and subsequent mounts with the panel left closed).
|
||
useEffect(() => {
|
||
if (filtersFirstPaintReady) return;
|
||
const t = window.setTimeout(() => setFiltersFirstPaintReady(true), 250);
|
||
return () => window.clearTimeout(t);
|
||
}, [filtersFirstPaintReady]);
|
||
|
||
const setFilterValue = (name: string, v: unknown) => {
|
||
setFilterValues((p) => ({ ...p, [name]: v }));
|
||
};
|
||
// Applying / clearing filters resets the pagination to the first page
|
||
// so the user is never left on a page that no longer exists. The row
|
||
// list is faded to opacity 0 (but kept mounted) by the dependency-
|
||
// driven effect that watches the same five values (search for
|
||
// `setPaginating`), which arms the `paginating` flag the moment
|
||
// any of `page`, `pageSize`, `sortField`, `sortDir`, or
|
||
// `appliedFilters` change. The flag is cleared again from inside
|
||
// `reload` once the new rows arrive, at which point the container
|
||
// fades back to opacity 1 and the new rows cross-fade into the
|
||
// slot the old ones occupied.
|
||
const applyFilters = () => {
|
||
setAppliedFilters(filterValues);
|
||
setPage(0);
|
||
};
|
||
const resetFilters = () => {
|
||
setFilterValues({});
|
||
setAppliedFilters({});
|
||
setPage(0);
|
||
};
|
||
|
||
// handleSort toggles between asc → desc → unsorted on the active column,
|
||
// and switches to a fresh column when a different header is clicked.
|
||
// Changing sort always returns to the first page so the user sees the
|
||
// top of the new ordering.
|
||
const handleSort = (key: string) => {
|
||
if (sortField === key) {
|
||
if (sortDir === "asc") {
|
||
setSortDir("desc");
|
||
} else {
|
||
// Third click clears the sort.
|
||
setSortField(null);
|
||
setSortDir("asc");
|
||
}
|
||
} else {
|
||
setSortField(key);
|
||
setSortDir("asc");
|
||
}
|
||
setPage(0);
|
||
};
|
||
|
||
const [editing, setEditing] = useState<TEntity | null>(null);
|
||
const [creating, setCreating] = useState(false);
|
||
// `dialogOpen` drives the MUI Dialog's `open` prop directly; flipping it
|
||
// to false starts the leave transition. `creating` / `editing` are
|
||
// cleared only after the transition finishes (handleDialogExited) so the
|
||
// dialog keeps rendering its current title and form contents through
|
||
// the fade-out — same trick the delete-confirm dialog uses with
|
||
// `lastPendingDeleteRef` below.
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
|
||
const openCreate = useCallback(() => {
|
||
setEditing(null);
|
||
setCreating(true);
|
||
setDialogOpen(true);
|
||
}, []);
|
||
|
||
const closeDialog = () => {
|
||
setDialogOpen(false);
|
||
};
|
||
|
||
const handleDialogExited = () => {
|
||
setCreating(false);
|
||
setEditing(null);
|
||
};
|
||
|
||
// Global keyboard shortcut: pressing "n" (physical key, layout-
|
||
// independent) anywhere on the page opens the Create dialog. We use
|
||
// `ev.code === "KeyN"` rather than `ev.key === "n"` so the shortcut
|
||
// fires regardless of keyboard layout — a Russian / Cyrillic layout
|
||
// would otherwise emit `ev.key === "т"`.
|
||
// Skipped while another dialog is open, when a modifier is held
|
||
// (so Ctrl+N etc. keep their browser meaning), and when the user is
|
||
// typing into an input.
|
||
// While the dialog is closing the MUI `open` prop is already false but
|
||
// `creating`/`editing` are still set (cleared by handleDialogExited);
|
||
// gate the shortcut on that combined "live" predicate so a stray "n"
|
||
// press during the leave animation doesn't immediately reopen.
|
||
const dialogLive = dialogOpen || creating || editing !== null;
|
||
useEffect(() => {
|
||
const handler = (ev: globalThis.KeyboardEvent) => {
|
||
if (ev.code !== "KeyN") return;
|
||
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
|
||
if (dialogLive) return;
|
||
const target = ev.target as HTMLElement | null;
|
||
if (target) {
|
||
const tag = target.tagName;
|
||
if (
|
||
tag === "INPUT" ||
|
||
tag === "TEXTAREA" ||
|
||
tag === "SELECT" ||
|
||
target.isContentEditable
|
||
)
|
||
return;
|
||
}
|
||
ev.preventDefault();
|
||
openCreate();
|
||
};
|
||
window.addEventListener("keydown", handler);
|
||
return () => window.removeEventListener("keydown", handler);
|
||
}, [dialogLive, openCreate]);
|
||
|
||
// Minimum visible window for the form dialog's "Saving…" busy state,
|
||
// mirroring the delete-dialog treatment. Without it, a sub-100 ms
|
||
// create/update round-trip causes the spinner + label to appear for
|
||
// a single frame and disappear — the user perceives that as a glitch
|
||
// rather than as the action having run. Both handlers gate `closeDialog`
|
||
// (and the error toast) on this so the on-screen busy state is held
|
||
// long enough to be perceived even on a fast network.
|
||
const ensureMinFormBusy = async (startTs: number) => {
|
||
const elapsed = performance.now() - startTs;
|
||
const minBusyMs = 350;
|
||
if (elapsed < minBusyMs) {
|
||
await new Promise((r) => setTimeout(r, minBusyMs - elapsed));
|
||
}
|
||
};
|
||
|
||
// Both handlers re-throw so the dialog's inline error UI keeps working
|
||
// (the dialog catches and renders the message inside an Alert). The toast
|
||
// is a complementary signal: it flashes a quick "X created" / "Failed to
|
||
// create X" status without interfering with the dialog's per-field error
|
||
// panel. notifyApiError silently swallows UnauthorizedError because the
|
||
// global 401 handler in AuthContext already announces those.
|
||
const handleCreate = async (form: Record<string, unknown>) => {
|
||
const body = config.toCreate(form) as TCreate;
|
||
const startTs = performance.now();
|
||
try {
|
||
await config.create(body);
|
||
} catch (e) {
|
||
await ensureMinFormBusy(startTs);
|
||
notifyApiError(notify, `Failed to create ${entityLabel}`, e);
|
||
throw e;
|
||
}
|
||
await ensureMinFormBusy(startTs);
|
||
closeDialog();
|
||
notify.success(`${entityLabel} created successfully`);
|
||
await reload();
|
||
};
|
||
|
||
const handleUpdate = async (form: Record<string, unknown>) => {
|
||
if (!editing) return;
|
||
const id = editing[config.idKey] as unknown as string | number;
|
||
const body = config.toUpdate(form, editing) as TUpdate;
|
||
const startTs = performance.now();
|
||
try {
|
||
await config.update(id, body);
|
||
} catch (e) {
|
||
await ensureMinFormBusy(startTs);
|
||
notifyApiError(notify, `Failed to update ${entityLabel}`, e);
|
||
throw e;
|
||
}
|
||
await ensureMinFormBusy(startTs);
|
||
closeDialog();
|
||
notify.success(`${entityLabel} updated successfully`);
|
||
await reload();
|
||
};
|
||
|
||
// -------- Delete confirmation ----------------------------------------
|
||
// A single MUI dialog drives both the per-row Delete icon and the bulk
|
||
// "Delete selected" button. `pendingDelete` carries the primary keys
|
||
// about to be removed; null means the dialog is closed.
|
||
const [pendingDelete, setPendingDelete] = useState<
|
||
| { kind: "single"; id: string | number; label: string }
|
||
| { kind: "bulk"; ids: (string | number)[] }
|
||
| null
|
||
>(null);
|
||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||
// Hold onto the last non-null `pendingDelete` so the dialog title and
|
||
// body keep showing the entity label/count during the MUI fade-out
|
||
// animation that fires after `setPendingDelete(null)`. Without this,
|
||
// the closing dialog briefly renders " will be permanently removed
|
||
// from the database." with an empty subject — which looks like the
|
||
// name "vanishes" the instant the user clicks Delete.
|
||
const lastPendingDeleteRef = useRef(pendingDelete);
|
||
if (pendingDelete !== null) {
|
||
lastPendingDeleteRef.current = pendingDelete;
|
||
}
|
||
const displayPendingDelete = pendingDelete ?? lastPendingDeleteRef.current;
|
||
|
||
const handleDelete = useCallback(
|
||
(row: TEntity) => {
|
||
const id = row[config.idKey] as unknown as string | number;
|
||
setPendingDelete({
|
||
kind: "single",
|
||
id,
|
||
label: `${config.title.slice(0, -1)} #${String(id)}`,
|
||
});
|
||
},
|
||
[config.idKey, config.title],
|
||
);
|
||
// Stable `setEditing` wrapper so memoised rows don't re-render just
|
||
// because the inline `() => setEditing(row)` arrow swaps reference
|
||
// every parent render. setEditing itself is already stable from
|
||
// useState, but the row needs a `(row) => void` callback. We also flip
|
||
// `dialogOpen` here to start the enter transition — closeDialog leaves
|
||
// it set to false, so reopening must explicitly set it back to true.
|
||
const handleEdit = useCallback((row: TEntity) => {
|
||
setCreating(false);
|
||
setEditing(row);
|
||
setDialogOpen(true);
|
||
}, []);
|
||
|
||
// pendingAction drives the row-action confirmation dialog (mirror of
|
||
// pendingDelete above). Null when no dialog is open. Pages opt into
|
||
// the dialog by setting `RowActionSpec.confirm`; actions without
|
||
// `confirm` skip this state and run inline in `handleRunAction`.
|
||
const [pendingAction, setPendingAction] = useState<
|
||
{ row: TEntity; action: RowActionSpec<TEntity> } | null
|
||
>(null);
|
||
const [actionBusy, setActionBusy] = useState(false);
|
||
// Same trick as `lastPendingDeleteRef`: hold the last non-null
|
||
// pendingAction so the dialog can keep displaying its title /
|
||
// description while it animates closed (after `setPendingAction(null)`).
|
||
const lastPendingActionRef = useRef(pendingAction);
|
||
if (pendingAction !== null) {
|
||
lastPendingActionRef.current = pendingAction;
|
||
}
|
||
const displayPendingAction = pendingAction ?? lastPendingActionRef.current;
|
||
const displayActionConfirm = useMemo(() => {
|
||
if (!displayPendingAction?.action.confirm) return null;
|
||
return displayPendingAction.action.confirm(displayPendingAction.row);
|
||
}, [displayPendingAction]);
|
||
|
||
// handleRunAction either opens the confirmation dialog (when the
|
||
// action declares one) or invokes the action immediately. Inline
|
||
// errors are surfaced through the global toast stack the same way
|
||
// the CrudPage's own Create / Edit / Delete failures are; the
|
||
// action's `onClick` itself is responsible for emitting any success
|
||
// toast.
|
||
const handleRunAction = useCallback(
|
||
(row: TEntity, action: RowActionSpec<TEntity>) => {
|
||
if (action.confirm) {
|
||
setPendingAction({ row, action });
|
||
return;
|
||
}
|
||
void Promise.resolve(action.onClick(row, { reload })).catch((e) => {
|
||
notifyApiError(notify, `Failed to ${action.label.toLowerCase()}`, e);
|
||
});
|
||
},
|
||
[reload, notify],
|
||
);
|
||
|
||
// confirmAction is the primary-button handler for the row-action
|
||
// dialog. Modeled on confirmDelete: minimum-visible busy state to
|
||
// suppress single-frame flickers, errors keep the dialog open so
|
||
// the user can retry, success closes it and reloads the table.
|
||
const confirmAction = async () => {
|
||
if (!pendingAction) return;
|
||
setActionBusy(true);
|
||
const startTs = performance.now();
|
||
const minBusyMs = 350;
|
||
let shouldClose = false;
|
||
try {
|
||
await Promise.resolve(
|
||
pendingAction.action.onClick(pendingAction.row, { reload }),
|
||
);
|
||
shouldClose = true;
|
||
} catch (e) {
|
||
notifyApiError(
|
||
notify,
|
||
`Failed to ${pendingAction.action.label.toLowerCase()}`,
|
||
e,
|
||
);
|
||
}
|
||
const elapsed = performance.now() - startTs;
|
||
if (elapsed < minBusyMs) {
|
||
await new Promise((r) => setTimeout(r, minBusyMs - elapsed));
|
||
}
|
||
if (shouldClose) {
|
||
setPendingAction(null);
|
||
// Hold the busy visuals through the dialog's exit animation so
|
||
// the button label / spinner don't snap back to idle while the
|
||
// dialog itself is still fading away on screen — same 260 ms
|
||
// as confirmDelete.
|
||
setTimeout(() => setActionBusy(false), 260);
|
||
} else {
|
||
setActionBusy(false);
|
||
}
|
||
};
|
||
|
||
// pluralEntityLabel formats the entity label for a count of things —
|
||
// singular when count === 1, the configured plural title otherwise. Used
|
||
// for bulk-delete toasts so 1 row reads "Squad deleted" while 5 rows
|
||
// read "5 Squads deleted".
|
||
const pluralEntityLabel = (count: number) =>
|
||
count === 1 ? entityLabel : config.title;
|
||
|
||
const confirmDelete = async () => {
|
||
if (!pendingDelete) return;
|
||
setDeleteBusy(true);
|
||
const startTs = performance.now();
|
||
// Minimum visible duration for the "Deleting…" state. Without
|
||
// this, a sub-100 ms network round-trip causes the label /
|
||
// spinner to flicker on for a single frame and back off, which
|
||
// reads as "the button glitched" rather than "the action ran".
|
||
const minBusyMs = 350;
|
||
let shouldClose = false;
|
||
try {
|
||
if (pendingDelete.kind === "single") {
|
||
try {
|
||
await config.remove(pendingDelete.id);
|
||
} catch (e) {
|
||
notifyApiError(notify, `Failed to delete ${entityLabel}`, e);
|
||
throw e;
|
||
}
|
||
notify.success(`${entityLabel} deleted successfully`);
|
||
} else {
|
||
// Run bulk deletes in parallel; settle-all so a single failure
|
||
// doesn't hide the successes from the reload below. We then split
|
||
// the results so the user sees both halves: a green "N deleted"
|
||
// chip for the wins and a red "M failed" chip for the losses.
|
||
const results = await Promise.allSettled(
|
||
pendingDelete.ids.map((id) => config.remove(id)),
|
||
);
|
||
const ok = results.filter((r) => r.status === "fulfilled").length;
|
||
const failed = results.length - ok;
|
||
if (ok > 0) {
|
||
notify.success(
|
||
`${ok} ${pluralEntityLabel(ok)} deleted successfully`,
|
||
);
|
||
}
|
||
if (failed > 0) {
|
||
// Surface the first error verbatim — almost always the same
|
||
// for every entry (permissions / FK constraint / connectivity)
|
||
// so picking a representative one keeps the toast useful.
|
||
const firstReason = (results.find(
|
||
(r) => r.status === "rejected",
|
||
) as PromiseRejectedResult | undefined)?.reason;
|
||
if (firstReason !== undefined) {
|
||
notifyApiError(
|
||
notify,
|
||
`Failed to delete ${failed} ${pluralEntityLabel(failed)}`,
|
||
firstReason,
|
||
);
|
||
} else {
|
||
notify.error(
|
||
`Failed to delete ${failed} ${pluralEntityLabel(failed)}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
shouldClose = true;
|
||
} catch {
|
||
// Already announced via `notifyApiError` inside the inner try.
|
||
// Swallow here so the rejection doesn't bubble to the global
|
||
// error handler — the dialog stays open (we never set
|
||
// `shouldClose = true`) so the user can retry or cancel.
|
||
}
|
||
const elapsed = performance.now() - startTs;
|
||
if (elapsed < minBusyMs) {
|
||
await new Promise((r) => setTimeout(r, minBusyMs - elapsed));
|
||
}
|
||
if (shouldClose) {
|
||
setPendingDelete(null);
|
||
setSelected(new Set());
|
||
await reload();
|
||
// Hold the busy visuals through the dialog's exit animation
|
||
// (~225 ms default MUI Dialog fade) so the button label /
|
||
// spinner don't snap back to the idle "Delete" state while
|
||
// the dialog itself is still fading away on screen.
|
||
setTimeout(() => setDeleteBusy(false), 260);
|
||
} else {
|
||
setDeleteBusy(false);
|
||
}
|
||
};
|
||
|
||
// -------- Selection / bulk actions ------------------------------------
|
||
// `selected` holds the primary-key values (string | number) of every
|
||
// currently checked row. We scope the selection to the rows that are
|
||
// actually visible — paginating, applying filters, sorting or reloading
|
||
// wipes it so the user can't accidentally bulk-act on rows they're no
|
||
// longer looking at.
|
||
const idOf = useCallback(
|
||
(row: TEntity): string | number =>
|
||
row[config.idKey] as unknown as string | number,
|
||
[config.idKey],
|
||
);
|
||
// Stabilise the columns reference so memoised rows don't re-render
|
||
// every time the parent's config object is reconstructed (which happens
|
||
// on each parent render when filterValues / loading / etc. change).
|
||
const columnsRef = useMemo(() => config.columns, [config.columns]);
|
||
|
||
// Subset of columns the user is allowed to sort by — mirrors the
|
||
// desktop header logic where each TableSortLabel is gated on
|
||
// `c.sortable !== false`. Computed once per columns reference so
|
||
// the mobile sort dropdown doesn't rebuild its option list on every
|
||
// unrelated render.
|
||
const sortableColumns = useMemo(
|
||
() => config.columns.filter((c) => (c.sortable ?? true) !== false),
|
||
[config.columns],
|
||
);
|
||
|
||
// -------- Smooth size transitions ------------------------------------
|
||
// The table card's height changes every time the row count does
|
||
// (filter applied, page changed, route switched). The Web Animations
|
||
// API smoothly tweens between the old and new heights — useLayoutEffect
|
||
// runs after the DOM update but before paint, so the captured height
|
||
// represents the *new* layout while `prevHeightRef.current` still holds
|
||
// the previous one.
|
||
//
|
||
// Speed (px / sec) is held constant rather than the duration: with a
|
||
// fixed 220 ms a small +1-row change felt sluggish while a +25-row
|
||
// jump looked rushed, because the same 220 ms had to cover wildly
|
||
// different deltas. Sliding at a constant px/sec keeps the visual
|
||
// velocity uniform regardless of how many rows just arrived from
|
||
// the API. The clamp on the bottom prevents micro-deltas from
|
||
// animating in 5 ms (effectively a snap), and the cap on top keeps
|
||
// very large jumps from feeling laggy.
|
||
//
|
||
// Desktop (md+) skips this tween entirely: the layout pins the Paper
|
||
// to `flex: 1` of the leftover viewport space, so its `offsetHeight`
|
||
// is governed by flex, not by row count. Animating `height: Xpx`
|
||
// through the Web Animations API would temporarily inline an
|
||
// explicit pixel height that fights the flex algorithm — the
|
||
// browser briefly resolves the Paper to that fixed height while
|
||
// it would have otherwise been "fill-leftover", which translates
|
||
// into the PageHeader / filter area visibly jumping toward the
|
||
// top each time fetched rows arrive (because the parent flex
|
||
// chain redistributes the leftover space differently when one
|
||
// child has an explicit height versus a `flex-basis: 0%`). On
|
||
// mobile / sm where the Paper is still block-laid-out and grows
|
||
// with content, the tween stays useful and runs unchanged.
|
||
const heightTweenTheme = useTheme();
|
||
const heightTweenIsDesktop = useMediaQuery(
|
||
heightTweenTheme.breakpoints.up("md"),
|
||
);
|
||
const tableRef = useRef<HTMLDivElement | null>(null);
|
||
const prevHeightRef = useRef<number | null>(null);
|
||
useLayoutEffect(() => {
|
||
const el = tableRef.current;
|
||
if (!el) return;
|
||
if (heightTweenIsDesktop) {
|
||
// Keep the ref bookkeeping in sync so a future viewport
|
||
// resize back to mobile starts from a sane baseline rather
|
||
// than from the height captured pre-`md`.
|
||
prevHeightRef.current = el.offsetHeight;
|
||
return;
|
||
}
|
||
const newHeight = el.offsetHeight;
|
||
if (
|
||
prevHeightRef.current !== null &&
|
||
prevHeightRef.current !== newHeight &&
|
||
typeof el.animate === "function"
|
||
) {
|
||
const delta = Math.abs(newHeight - prevHeightRef.current);
|
||
// Calibrated so big swaps don't drag: at 1300 px/sec, a
|
||
// typical single-row delta (~50 px) still clears the
|
||
// 240 ms floor; a 200 px change takes ~154 ms (floored);
|
||
// a ~40-row swap (~800 px) takes ~615 ms; a full-page
|
||
// resize (e.g. 100 → 10 rows, ~4500 px) takes ~3.5 s
|
||
// mathematically but is hard-capped at 560 ms so very
|
||
// large reflows never feel like waiting.
|
||
const PX_PER_SEC = 1300;
|
||
// Floor of 240 ms is the inflection where motion reads
|
||
// as "deliberate movement" rather than a snap on 60 Hz
|
||
// displays — going below ~220 ms with a symmetric easing
|
||
// curve loses the perceptible glide.
|
||
const MIN_MS = 240;
|
||
// Ceiling of 560 ms keeps even the biggest page-size
|
||
// changes from dragging — past ~600 ms a height tween
|
||
// tips over from "the table is settling" into "the table
|
||
// is slow", regardless of how many rows just arrived.
|
||
const MAX_MS = 560;
|
||
const duration = Math.max(
|
||
MIN_MS,
|
||
Math.min(MAX_MS, (delta / PX_PER_SEC) * 1000),
|
||
);
|
||
el.animate(
|
||
[
|
||
{ height: `${prevHeightRef.current}px` },
|
||
{ height: `${newHeight}px` },
|
||
],
|
||
// Symmetric S-curve (gentle ease-in-out). The previous
|
||
// Material-standard `cubic-bezier(0.4, 0, 0.2, 1)`
|
||
// started fast and decelerated — the card lurched out
|
||
// of its starting height and then crawled into place.
|
||
// `0.45, 0, 0.55, 1` is a balanced sigmoid: slow at both
|
||
// ends and brisk through the middle, which reads as a
|
||
// continuous, "pulled" reflow.
|
||
{ duration, easing: "cubic-bezier(0.45, 0, 0.55, 1)" },
|
||
);
|
||
}
|
||
prevHeightRef.current = newHeight;
|
||
}, [rows.length, hasFetched, heightTweenIsDesktop]);
|
||
|
||
// -------- Row-count-scaled animation timings -------------------------
|
||
// The dim overlay and the top progress bar both fade with a duration
|
||
// that grows with the size of the visible table. Few rows → snappy
|
||
// (the table is small, so the eye absorbs the change instantly);
|
||
// many rows → graceful (a wall of cells benefits from a longer
|
||
// breath so the dim looks like a deliberate veil rather than a
|
||
// strobe).
|
||
//
|
||
// The square-root curve climbs steeply for the first few rows then
|
||
// flattens out — perceptually we're far more sensitive to the
|
||
// difference between 0 and 5 rows than between 50 and 100, so the
|
||
// ramp matches that. `Math.sqrt(50) ≈ 7.07`, so the divisor of 7
|
||
// saturates the scale near 50 rows and then plateaus.
|
||
//
|
||
// The height-tween animation already scales with pixel delta
|
||
// (`PX_PER_SEC` / `MIN_MS` / `MAX_MS` above), which is itself a
|
||
// proxy for "how many rows just appeared/disappeared", so we don't
|
||
// need a separate row-count factor there.
|
||
const rowAnimScale = Math.min(1, Math.sqrt(rows.length) / 7);
|
||
// 220 ms (empty / 1 row) → 460 ms (50+ rows). The upper bound is
|
||
// intentionally compressed: at the previous 700 ms ceiling, big
|
||
// tables felt sluggish — a wall of 50+ rows benefits from a
|
||
// longer-than-snappy fade, but past ~450 ms the user starts
|
||
// *waiting* on the dim instead of perceiving it as stale-state
|
||
// feedback.
|
||
const dimDurationMs = Math.round(220 + rowAnimScale * 240);
|
||
// 180 ms (empty / 1 row) → 360 ms (50+ rows). Still slightly
|
||
// faster than the dim so the progress bar leads the dim into /
|
||
// out of view, and tightened in lockstep so big tables don't
|
||
// sit watching a slow strip of motion above static cells.
|
||
const progressDurationMs = Math.round(180 + rowAnimScale * 180);
|
||
|
||
const [selected, setSelected] = useState<Set<string | number>>(new Set());
|
||
// Rebuild the selection so it only ever contains keys that exist on the
|
||
// current page (e.g. a row was deleted by another tab).
|
||
useEffect(() => {
|
||
setSelected((prev) => {
|
||
if (prev.size === 0) return prev;
|
||
const visible = new Set<string | number>(rows.map(idOf));
|
||
let changed = false;
|
||
const next = new Set<string | number>();
|
||
for (const k of prev) {
|
||
if (visible.has(k)) next.add(k);
|
||
else changed = true;
|
||
}
|
||
return changed ? next : prev;
|
||
});
|
||
}, [rows, idOf]);
|
||
// Clear selection whenever the user paginates, sorts, or applies a new
|
||
// filter — bulk actions should only target rows the user can see right
|
||
// now.
|
||
useEffect(() => {
|
||
setSelected(new Set());
|
||
}, [page, pageSize, sortField, sortDir, appliedFilters]);
|
||
// Arm the `paginating` flag the moment the user paginates / sorts /
|
||
// changes a filter. We don't clear `rows` here on purpose — clearing
|
||
// would collapse the table card from "25 visible rows" down to the
|
||
// bare empty-state placeholder height, which reads as a sudden
|
||
// layout snap. Instead, the existing rows stay mounted (so the
|
||
// card keeps its current height) but get faded to opacity 0 via
|
||
// the gate on the desktop TableBody / mobile row-cards Stack
|
||
// below — the column headers and mobile toolbar above them stay
|
||
// fully visible. When the new data lands, `reload`'s
|
||
// `setRows(reconcileRows(...))` swaps the entries in place and
|
||
// `setPaginating(false)` lets the body fade back to opacity 1, so
|
||
// the new rows appear to cross-fade into the slot the old ones
|
||
// were occupying. The empty-state placeholder stays hidden for
|
||
// the same window so "No data yet" doesn't flash mid-roundtrip.
|
||
//
|
||
// Skipped on the very first run (initial mount) — `rows` is already
|
||
// `[]` and we haven't even fetched anything, so there's nothing to
|
||
// hide and nothing to gate against. Without this guard the initial
|
||
// empty-state placeholder would briefly arm `paginating` for the
|
||
// duration of the first reload, keeping the "No data yet" copy
|
||
// hidden longer than necessary on cold mounts that legitimately
|
||
// resolve to an empty list.
|
||
const dataDepsFirstRunRef = useRef(true);
|
||
useEffect(() => {
|
||
if (dataDepsFirstRunRef.current) {
|
||
dataDepsFirstRunRef.current = false;
|
||
return;
|
||
}
|
||
setPaginating(true);
|
||
}, [page, pageSize, sortField, sortDir, appliedFilters]);
|
||
|
||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||
const someSelected = selected.size > 0 && !allSelected;
|
||
const toggleAll = () => {
|
||
setSelected(allSelected ? new Set() : new Set(rows.map(idOf)));
|
||
};
|
||
const toggleRow = useCallback(
|
||
(row: TEntity) => {
|
||
const id = idOf(row);
|
||
setSelected((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
return next;
|
||
});
|
||
},
|
||
[idOf],
|
||
);
|
||
|
||
const handleBulkDelete = () => {
|
||
if (selected.size === 0) return;
|
||
setPendingDelete({ kind: "bulk", ids: Array.from(selected) });
|
||
};
|
||
|
||
return (
|
||
// Desktop (md+): flex column that fills the page-content slot
|
||
// exactly, so the table card below can `flex: 1` and stay inside
|
||
// the viewport regardless of how many rows / how tall a filter
|
||
// panel is open. Mobile: default block layout — the page
|
||
// continues to scroll naturally on phones, where a "fit to
|
||
// viewport" table card would just feel cramped.
|
||
<Box
|
||
sx={{
|
||
display: { md: "flex" },
|
||
flexDirection: { md: "column" },
|
||
flex: { md: 1 },
|
||
minHeight: { md: 0 },
|
||
}}
|
||
>
|
||
<PageHeader
|
||
title={config.title}
|
||
subtitle={config.subtitle}
|
||
icon={config.icon}
|
||
actions={
|
||
// `flexWrap: "wrap"` lets the toolbar fold onto a second row
|
||
// on very narrow phones (≤ 360 px) where Filters + Refresh +
|
||
// New together don't fit on a single line. Stack's default
|
||
// `spacing` doesn't apply across wrapped lines, so we add an
|
||
// explicit `rowGap` to keep wrapped buttons from touching.
|
||
<Stack
|
||
direction="row"
|
||
// Spacing + size match the topbar in Layout.tsx so the
|
||
// page-level toolbar reads as a continuation of the
|
||
// global header chrome: 1.5 (= 12 px) between buttons,
|
||
// 40 × 40 footprint per button, `borderRadius: 2`,
|
||
// `fontSize="small"` icons inside.
|
||
spacing={1.5}
|
||
alignItems="center"
|
||
sx={{ flexWrap: "wrap", rowGap: 1 }}
|
||
>
|
||
{hasCustomColumnWidths && (
|
||
<Tooltip title="Reset column widths">
|
||
<IconButton
|
||
onClick={resetColumnWidths}
|
||
size="medium"
|
||
aria-label="Reset column widths"
|
||
sx={{
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 2,
|
||
color: "text.primary",
|
||
// Same hover-gating as the Refresh sibling: only paint
|
||
// the hover tint on real hover-capable pointers at
|
||
// desktop widths so a tap on touch / DevTools mobile
|
||
// emulation doesn't leave the tint stuck on.
|
||
"@media (hover: hover) and (min-width: 600px)": {
|
||
"&:hover": { backgroundColor: "action.hover" },
|
||
},
|
||
}}
|
||
>
|
||
<RestartAltIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
)}
|
||
<Tooltip title="Refresh">
|
||
<span>
|
||
<IconButton
|
||
onClick={() => {
|
||
setRefreshSpinning(true);
|
||
void reload();
|
||
}}
|
||
size="medium"
|
||
sx={{
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 2,
|
||
color: "text.primary",
|
||
// Mobile browsers leave `:hover` stuck on the
|
||
// last-tapped button until the user taps elsewhere,
|
||
// which paints a hover tint around Refresh after
|
||
// every tap. Gate the hover rule on a real
|
||
// hover-capable pointer AND a desktop-width
|
||
// viewport so the tint doesn't fire either on real
|
||
// touch devices or in Chrome DevTools' mobile
|
||
// emulation mode (which keeps `(hover: hover)`
|
||
// true because the host machine still has a
|
||
// mouse, but matches the narrow viewport of a
|
||
// phone). 599.95 px is the upper edge of MUI's
|
||
// `xs` breakpoint.
|
||
"@media (hover: hover) and (min-width: 600px)": {
|
||
"&:hover": { backgroundColor: "action.hover" },
|
||
},
|
||
}}
|
||
>
|
||
<RefreshIcon
|
||
fontSize="small"
|
||
onAnimationIteration={() => {
|
||
if (!loading) setRefreshSpinning(false);
|
||
}}
|
||
sx={{
|
||
animation: refreshSpinning
|
||
? "sb-refresh-spin 0.35s linear infinite"
|
||
: "none",
|
||
"@keyframes sb-refresh-spin": {
|
||
from: { transform: "rotate(0deg)" },
|
||
to: { transform: "rotate(360deg)" },
|
||
},
|
||
}}
|
||
/>
|
||
</IconButton>
|
||
</span>
|
||
</Tooltip>
|
||
{filterSpecs.length > 0 && (
|
||
<Tooltip
|
||
title={
|
||
activeFilterCount > 0
|
||
? `Filters · ${activeFilterCount} active`
|
||
: "Filters"
|
||
}
|
||
>
|
||
{/* Filters trigger collapsed to an icon-only button.
|
||
The active-count is rendered as a small Badge dot
|
||
in the top-right corner so the user still gets a
|
||
visual cue when filters are applied without the
|
||
"Filters" word taking up toolbar space. */}
|
||
<Badge
|
||
color="primary"
|
||
badgeContent={activeFilterCount}
|
||
invisible={activeFilterCount === 0}
|
||
overlap="circular"
|
||
sx={{
|
||
"& .MuiBadge-badge": {
|
||
height: 16,
|
||
minWidth: 16,
|
||
fontSize: 10,
|
||
fontWeight: 700,
|
||
padding: "0 4px",
|
||
},
|
||
}}
|
||
>
|
||
<IconButton
|
||
onClick={() => setShowFilters((s) => !s)}
|
||
size="medium"
|
||
aria-label="Filters"
|
||
sx={{
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 2,
|
||
// When the panel is open OR there's an active
|
||
// filter, paint the button in accent so it
|
||
// reads as "engaged"; otherwise it sits
|
||
// alongside Refresh as a quiet toolbar action.
|
||
color:
|
||
showFilters || activeFilterCount > 0
|
||
? "var(--sb-accent)"
|
||
: "text.primary",
|
||
// Desktop split: 12 % accent at rest, 20 % under
|
||
// a real hover (handled in the desktop branch
|
||
// below). The mobile branch promotes the
|
||
// engaged tint straight to 20 % so it doesn't
|
||
// depend on a hover state the user can't reach.
|
||
bgcolor:
|
||
showFilters || activeFilterCount > 0
|
||
? "color-mix(in srgb, var(--sb-accent) 12%, transparent)"
|
||
: "transparent",
|
||
// "Mobile version" branch — matches both real
|
||
// touch devices (`(hover: none)`) AND Chrome
|
||
// DevTools' mobile-emulation mode (which keeps
|
||
// `(hover: hover)` true because the host
|
||
// machine still has a mouse, but does shrink
|
||
// the viewport). 599.95 px is the upper edge
|
||
// of MUI's `xs` breakpoint, so this catches
|
||
// every "phone-shaped" layout regardless of
|
||
// input type.
|
||
//
|
||
// On the phone breakpoint we deliberately drop
|
||
// the accent-tinted engaged-state background
|
||
// entirely — that backdrop was what the user
|
||
// perceived as a halo "appearing on reload"
|
||
// (since `usePersistedFiltersOpen` rehydrates
|
||
// `showFilters: true` from localStorage on
|
||
// remount, the engaged tint would render in
|
||
// the very first frame after a refresh, with
|
||
// no tap to attribute it to). The Badge in
|
||
// the upper-right corner already carries the
|
||
// active-filter count, and the icon glyph
|
||
// recolours to `var(--sb-accent)` for the
|
||
// engaged state, so the indication isn't
|
||
// lost — it just stops painting a filled
|
||
// background. `transition: color` (no
|
||
// background-color) keeps the icon recolour
|
||
// smooth without ever needing to animate a
|
||
// backdrop that no longer exists.
|
||
"@media (hover: none), (max-width: 599.95px)": {
|
||
bgcolor: "transparent",
|
||
transition: "color 0.14s ease",
|
||
},
|
||
// Desktop branch — only fires when the user
|
||
// genuinely has a hover-capable pointer AND
|
||
// the layout is wide enough to read as
|
||
// "desktop". Touch devices and the DevTools
|
||
// mobile preset are both excluded so a tap
|
||
// there doesn't leave the 20 %-tint hover
|
||
// background stuck on the button (which is
|
||
// the original "halo remains after tap" bug).
|
||
"@media (hover: hover) and (min-width: 600px)": {
|
||
"&:hover": {
|
||
backgroundColor:
|
||
showFilters || activeFilterCount > 0
|
||
? "color-mix(in srgb, var(--sb-accent) 20%, transparent)"
|
||
: "action.hover",
|
||
},
|
||
},
|
||
}}
|
||
>
|
||
<FilterAltIcon fontSize="small" />
|
||
</IconButton>
|
||
</Badge>
|
||
</Tooltip>
|
||
)}
|
||
{/* The "create" pill is intentionally smaller (32 × 32)
|
||
than the neutral Refresh / Filters siblings, but it's
|
||
wrapped in a 40 × 40 layout slot so the lower toolbar
|
||
stays exactly the same total width as the upper bar's
|
||
3-button row (each is 3 × 40 px + 2 × 12 px gap =
|
||
144 px). Without this slot the lower toolbar would
|
||
end 8 px earlier and the right edges of the two bars
|
||
wouldn't line up. */}
|
||
<Box
|
||
sx={{
|
||
width: 40,
|
||
height: 40,
|
||
display: "grid",
|
||
placeItems: "center",
|
||
}}
|
||
>
|
||
<Tooltip
|
||
title={`New ${config.title.slice(0, -1).toLowerCase()}`}
|
||
>
|
||
<IconButton
|
||
onClick={openCreate}
|
||
size="small"
|
||
aria-label="Create new"
|
||
sx={{
|
||
width: 32,
|
||
height: 32,
|
||
// Tighter corner radius on the primary "create"
|
||
// action so it reads as a distinctly different
|
||
// control next to the more rounded Refresh /
|
||
// Filters siblings.
|
||
borderRadius: 1,
|
||
color: "var(--sb-accent-contrast, #ffffff)",
|
||
bgcolor: "var(--sb-accent)",
|
||
"&:hover": {
|
||
backgroundColor:
|
||
"color-mix(in srgb, var(--sb-accent) 88%, transparent)",
|
||
},
|
||
}}
|
||
>
|
||
<AddIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Box>
|
||
</Stack>
|
||
}
|
||
/>
|
||
{filterSpecs.length > 0 && (
|
||
// `flexShrink: 0` keeps the desktop flex column from squeezing
|
||
// the Collapse below the height it just measured for its own
|
||
// open animation. Without it, when this CrudPage mounts on a
|
||
// page where filters were persisted as open, the freshly
|
||
// re-rendered Collapse and the `flex: 1` Paper sibling would
|
||
// briefly compete for the same vertical pixels — the flex
|
||
// algorithm shrinks the Collapse on the same frame MUI is
|
||
// tweening its height up, the CSS `transitionend` never
|
||
// matches the ever-moving target, and the panel snaps open
|
||
// without animation. Forcing `flex-shrink: 0` lets the
|
||
// Collapse own its full natural height for the duration of
|
||
// its own transition; the Paper's `flex: 1` minus its
|
||
// `minHeight: 0` already lets it shrink to make room.
|
||
<Collapse
|
||
in={showFilters}
|
||
unmountOnExit
|
||
timeout={180}
|
||
// Lift the first-paint gate once the panel has finished
|
||
// animating open — this is what makes the table wait until
|
||
// the filter panel is fully visible before it loads rows
|
||
// when the user navigates between admin pages with
|
||
// persisted-open filters. After the gate has lifted once
|
||
// it stays lifted; later open/close toggles by the user
|
||
// re-fire `onEntered` but the setter is a no-op when
|
||
// already true.
|
||
onEntered={() => setFiltersFirstPaintReady(true)}
|
||
sx={{ flexShrink: 0 }}
|
||
>
|
||
{/* Pressing Enter anywhere inside the filter panel submits the
|
||
current draft — same as clicking Search. Avoids a full <form>
|
||
wrapper which would conflict with the table form-less buttons. */}
|
||
<Box
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
applyFilters();
|
||
}
|
||
}}
|
||
sx={{
|
||
mb: 2,
|
||
p: 2,
|
||
pl: 2.25,
|
||
borderRadius: 2.5,
|
||
border: "1px solid",
|
||
borderColor: "divider",
|
||
// Custom request: dedicated surfaces per theme so the filter
|
||
// panel reads as a distinct slab — `#f5f7fa` on light,
|
||
// `#191919` on dark.
|
||
bgcolor: (t) => (t.palette.mode === "light" ? "#f5f7fa" : "#191919"),
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 1.5,
|
||
position: "relative",
|
||
overflow: "hidden",
|
||
// Subtle accent bar on the left edge — visually couples the
|
||
// panel to the toolbar's "Filters" button without shouting.
|
||
"&::before": {
|
||
content: '""',
|
||
position: "absolute",
|
||
top: 12,
|
||
bottom: 12,
|
||
left: 0,
|
||
width: 2,
|
||
borderRadius: 2,
|
||
bgcolor: "primary.main",
|
||
opacity: 0.6,
|
||
},
|
||
}}
|
||
>
|
||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.5 }}>
|
||
<Box
|
||
sx={{
|
||
width: 22,
|
||
height: 22,
|
||
borderRadius: 1,
|
||
display: "grid",
|
||
placeItems: "center",
|
||
bgcolor: "action.selected",
|
||
color: "text.secondary",
|
||
}}
|
||
>
|
||
<FilterAltIcon sx={{ fontSize: 14 }} />
|
||
</Box>
|
||
<Typography
|
||
variant="subtitle2"
|
||
color="text.primary"
|
||
sx={{ fontWeight: 600, letterSpacing: 0.2 }}
|
||
>
|
||
Filters
|
||
</Typography>
|
||
{activeFilterCount > 0 && (
|
||
<Chip
|
||
size="small"
|
||
color="primary"
|
||
label={`${activeFilterCount} active`}
|
||
sx={{ height: 20, fontSize: 11 }}
|
||
/>
|
||
)}
|
||
<Box sx={{ flexGrow: 1 }} />
|
||
<Button
|
||
size="small"
|
||
color="inherit"
|
||
startIcon={<FilterAltOffIcon fontSize="small" />}
|
||
disabled={activeFilterCount === 0 && draftFilterCount === 0}
|
||
onClick={resetFilters}
|
||
sx={{ textTransform: "none" }}
|
||
>
|
||
Clear
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
variant="contained"
|
||
color="primary"
|
||
startIcon={<SearchIcon fontSize="small" />}
|
||
onClick={applyFilters}
|
||
disabled={!filtersDirty}
|
||
>
|
||
Search
|
||
</Button>
|
||
</Stack>
|
||
{/* Each filter has a fixed width — datetime-range cells (which
|
||
pack two pickers side by side) and any cell explicitly
|
||
marked `wide: true` get 1.5× the slot — and the row simply
|
||
flex-wraps when there isn't enough horizontal space, so the
|
||
number of filters per row adapts to the viewport without
|
||
forcing every cell to share the same fractional column. */}
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
flexWrap: "wrap",
|
||
gap: `${FILTER_GAP}px`,
|
||
}}
|
||
>
|
||
{filterSpecs.map((f) => (
|
||
<Box
|
||
key={f.name}
|
||
sx={{
|
||
// On mobile (< 900 px) every filter cell takes the
|
||
// full row — the fixed 240 / 372 px slot layout from
|
||
// desktop squeezes two half-height inputs into
|
||
// viewports too narrow to comfortably use them,
|
||
// especially `datetime-range` which packs two
|
||
// pickers side-by-side.
|
||
width: {
|
||
xs: "100%",
|
||
md:
|
||
f.type === "datetime-range" || f.wide
|
||
? WIDE_FILTER_WIDTH
|
||
: FILTER_WIDTH,
|
||
},
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<FilterField
|
||
spec={f}
|
||
value={filterValues[f.name]}
|
||
onChange={(v) => setFilterValue(f.name, v)}
|
||
/>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
</Collapse>
|
||
)}
|
||
<Paper
|
||
ref={tableRef}
|
||
variant="outlined"
|
||
sx={(t) => ({
|
||
overflow: "hidden",
|
||
position: "relative",
|
||
// Desktop: claim the leftover vertical space below the
|
||
// PageHeader / filter panel and lay the inner pieces
|
||
// (progress bar, TableContainer, TablePagination) out as a
|
||
// flex column so the *rows* are the only part that scrolls.
|
||
// The card stays pinned inside the browser window — no
|
||
// page-level scroll — regardless of row count or page size.
|
||
display: { md: "flex" },
|
||
flexDirection: { md: "column" },
|
||
flex: { md: 1 },
|
||
minHeight: { md: 0 },
|
||
// Soft drop shadow gives the table some depth without making
|
||
// the rest of the layout look heavy. Tuned per mode so the
|
||
// shadow stays visible on white as well as on dark.
|
||
boxShadow:
|
||
t.palette.mode === "light"
|
||
? "0 1px 0 rgba(15,23,42,0.04), 0 8px 22px rgba(15,23,42,0.10)"
|
||
: "0 1px 0 rgba(255,255,255,0.02), 0 12px 28px rgba(0,0,0,0.28)",
|
||
})}
|
||
>
|
||
{/* Thin top progress bar visible during any load (initial or reload).
|
||
Gated on `loadingVisible` (loading + ~180 ms) for *refreshes* so
|
||
fast roundtrips don't flash a strip of motion at all, but the
|
||
very first load bypasses that lag (`!hasFetched`) — the user
|
||
sees the progress bar the moment the table mounts, sitting
|
||
on top of the empty-state placeholder we now reserve. */}
|
||
<Box
|
||
sx={{
|
||
position: "absolute",
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
height: 2,
|
||
zIndex: 2,
|
||
// Initial load: `!hasFetched` makes the bar appear instantly so
|
||
// the empty placeholder doesn't sit silent while the first
|
||
// request is in flight.
|
||
//
|
||
// Reloads: gated on the lagging `loadingVisible` so a refresh
|
||
// that finishes inside the ~180 ms threshold never flashes a
|
||
// strip of motion. (We no longer additionally gate on
|
||
// `rows.length > 0`; clicking Refresh on an empty list now
|
||
// also shows the bar, which is the expected feedback for a
|
||
// user-initiated action.)
|
||
//
|
||
// Gating on `loading` (in addition to the lagging
|
||
// `loadingVisible`) so the bar disappears the moment the
|
||
// fetch finishes — without it, the post-fetch render would
|
||
// briefly keep the bar visible while `loadingVisible` waits
|
||
// for its useEffect to flip it off, which read as a "double
|
||
// render" flicker right when the rows appeared.
|
||
opacity: loading && (loadingVisible || !hasFetched) ? 1 : 0,
|
||
transition: `opacity ${progressDurationMs}ms cubic-bezier(0.45, 0, 0.55, 1)`,
|
||
pointerEvents: "none",
|
||
}}
|
||
>
|
||
<LinearProgress sx={{ height: 2 }} />
|
||
</Box>
|
||
{/* Bulk-action toolbar: appears as a thin top strip on the table
|
||
card whenever at least one row is selected. Replaces the per-
|
||
row delete button for batch operations and gives the user a
|
||
one-click way to clear the selection. Rendered as a sibling
|
||
*above* the scrolling TableContainer (rather than inside it)
|
||
so the strip stays visible while the user scrolls the rows
|
||
vertically — otherwise it would scroll out of the viewport
|
||
together with the body and the user would lose the bulk-
|
||
action affordance the moment they paged past the first
|
||
screenful of selected rows. */}
|
||
<Collapse in={selected.size > 0} unmountOnExit>
|
||
<Stack
|
||
direction="row"
|
||
alignItems="center"
|
||
spacing={1.5}
|
||
sx={{
|
||
px: 2,
|
||
py: 1,
|
||
borderBottom: "1px solid",
|
||
borderColor: "divider",
|
||
// Neutral tint — keeps the table chrome from flashing the
|
||
// accent color when the user toggles a checkbox.
|
||
bgcolor: "action.hover",
|
||
}}
|
||
>
|
||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||
{selected.size} selected
|
||
</Typography>
|
||
<Box sx={{ flexGrow: 1 }} />
|
||
<Button
|
||
size="small"
|
||
color="inherit"
|
||
onClick={() => setSelected(new Set())}
|
||
disabled={deleteBusy}
|
||
sx={{ textTransform: "none" }}
|
||
>
|
||
Clear
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
variant="contained"
|
||
color="error"
|
||
startIcon={<DeleteIcon fontSize="small" />}
|
||
onClick={handleBulkDelete}
|
||
disabled={deleteBusy}
|
||
>
|
||
Delete selected
|
||
</Button>
|
||
</Stack>
|
||
</Collapse>
|
||
<TableContainer
|
||
ref={scrollContainerRef}
|
||
sx={{
|
||
// Desktop: take the leftover vertical space inside the
|
||
// Paper card and scroll the rows internally — the
|
||
// pagination bar at the bottom of the card stays pinned
|
||
// to the bottom of the viewport instead of sitting below
|
||
// the fold. `minHeight: 0` is required for the
|
||
// flex-fill child to be allowed to shrink below its
|
||
// intrinsic content height. MUI's TableContainer ships
|
||
// with `overflow-x: auto` only; we add `overflow-y:
|
||
// auto` here so the Y-axis scroll lives inside the
|
||
// card too.
|
||
flex: { md: 1 },
|
||
minHeight: { md: 0 },
|
||
overflowY: { md: "auto" },
|
||
// Soft dim while a reload is in flight (only when there's
|
||
// existing data to dim — first load stays at full opacity).
|
||
// Gated on `loadingVisible` rather than `loading` so a
|
||
// refresh that finishes inside the ~180 ms threshold never
|
||
// visibly dims the table at all (no flash on fast networks).
|
||
//
|
||
// Resting dim of 0.78 is a gentle attenuation — the table
|
||
// content stays clearly legible mid-reload while still
|
||
// reading as "stale". The transition duration scales
|
||
// with the number of currently-visible rows (`rowAnimScale`
|
||
// above): a 1-row table dims/un-dims in ~220 ms (snappy,
|
||
// because the table is small and the eye absorbs the
|
||
// change instantly), a 50+ row table dims/un-dims in
|
||
// ~700 ms (graceful, because a wall of cells benefits
|
||
// from a longer breath). The symmetric S-curve makes the
|
||
// change read as a deliberate "pulled" veil at every
|
||
// size rather than a sudden flash.
|
||
// `loadingVisible` is delayed by ~180 ms after `loading`
|
||
// flips on, but it's also delayed by one render *off* —
|
||
// setLoadingVisible(false) only runs in the cleanup effect
|
||
// after `loading` flips to false. Without the extra
|
||
// `loading &&` gate the post-fetch render would dim the
|
||
// freshly-mounted rows for one frame before the effect
|
||
// fired, which read as a "double render" flicker. With
|
||
// the gate, the dim disappears the moment the fetch
|
||
// resolves regardless of `loadingVisible`'s lag.
|
||
opacity: loading && loadingVisible && rows.length > 0 ? 0.78 : 1,
|
||
transition: `opacity ${dimDurationMs}ms cubic-bezier(0.45, 0, 0.55, 1)`,
|
||
// `pointerEvents` follows the same delayed signal so a
|
||
// sub-180 ms refresh never briefly disables hovers / clicks
|
||
// (which used to surface as a tiny "dead zone" over the
|
||
// table for the duration of a fast roundtrip).
|
||
pointerEvents: loading && loadingVisible ? "none" : "auto",
|
||
}}
|
||
>
|
||
{isMobile ? (
|
||
<Box sx={{ p: 1.5 }}>
|
||
{rows.length === 0 ? (
|
||
// Mirror of the desktop empty state, just outside any
|
||
// table cell so we can drop the colSpan plumbing.
|
||
//
|
||
// Rendered immediately on mount so the card reserves
|
||
// the same vertical space the empty-state CTA will
|
||
// eventually occupy. While the very first reload is
|
||
// still in flight (`!hasFetched`) the stack is kept
|
||
// invisible — the user doesn't see the "No data yet"
|
||
// copy or the Create button flash before we know
|
||
// whether the API actually has rows for them.
|
||
<Stack
|
||
alignItems="center"
|
||
spacing={1.5}
|
||
sx={{
|
||
py: 6,
|
||
// Fade in once the first reload finishes — we keep the
|
||
// stack mounted from the start so the card reserves
|
||
// its full height, but cross-fade the contents in
|
||
// gently instead of snapping from `visibility:
|
||
// hidden` to `visible`. `pointerEvents` follow the
|
||
// same gate so the (visually invisible) Create
|
||
// button never receives stray taps before the
|
||
// empty-state has actually been confirmed.
|
||
//
|
||
// The `!paginating` clause keeps the placeholder
|
||
// hidden while the user's pagination / sort /
|
||
// filter change is in flight: in the common case
|
||
// the previous rows are still mounted (faded to
|
||
// opacity 0 by the inner row-cards Stack down
|
||
// below) and rows.length is non-zero, so this
|
||
// branch isn't even rendered. The gate matters
|
||
// for the case where the previous page was
|
||
// already empty AND the user paginates / changes
|
||
// a filter — then this branch IS rendered but
|
||
// should stay hidden during the round-trip, not
|
||
// flash "No data yet" mid-fetch.
|
||
opacity: hasFetched && !paginating ? 1 : 0,
|
||
transition:
|
||
"opacity 320ms cubic-bezier(0.22, 0.61, 0.36, 1)",
|
||
pointerEvents:
|
||
hasFetched && !paginating ? "auto" : "none",
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
width: 56,
|
||
height: 56,
|
||
borderRadius: 2.5,
|
||
display: "grid",
|
||
placeItems: "center",
|
||
color: "text.secondary",
|
||
bgcolor: "action.hover",
|
||
border: "1px solid",
|
||
borderColor: "divider",
|
||
}}
|
||
>
|
||
<InboxRoundedIcon />
|
||
</Box>
|
||
<Box sx={{ color: "text.secondary", fontSize: 13 }}>
|
||
No data yet
|
||
</Box>
|
||
<Button
|
||
variant="contained"
|
||
color="primary"
|
||
size="small"
|
||
startIcon={<AddIcon />}
|
||
onClick={openCreate}
|
||
>
|
||
Create the first one
|
||
</Button>
|
||
</Stack>
|
||
) : (
|
||
<Stack spacing={1.25}>
|
||
{/* Mobile toolbar that replaces the desktop table's
|
||
header row: the select-all checkbox sits on the
|
||
left, and a compact "sort by + direction"
|
||
control sits on the right. The Stack uses
|
||
`flexWrap: wrap` so on very narrow phones the
|
||
sort group can drop onto its own line instead
|
||
of pushing into the checkbox. */}
|
||
{rows.length > 0 && (
|
||
<Stack
|
||
direction="row"
|
||
alignItems="center"
|
||
spacing={1}
|
||
sx={{
|
||
pl: 0.5,
|
||
pb: 0.5,
|
||
flexWrap: "wrap",
|
||
rowGap: 1,
|
||
}}
|
||
>
|
||
<Stack
|
||
direction="row"
|
||
alignItems="center"
|
||
spacing={0.5}
|
||
sx={{ color: "text.secondary", fontSize: 12.5 }}
|
||
>
|
||
<Checkbox
|
||
size="small"
|
||
color="primary"
|
||
indeterminate={someSelected}
|
||
checked={allSelected}
|
||
onChange={toggleAll}
|
||
inputProps={{ "aria-label": "select all rows" }}
|
||
/>
|
||
<Box>Select all</Box>
|
||
</Stack>
|
||
<Box sx={{ flexGrow: 1 }} />
|
||
<Stack
|
||
direction="row"
|
||
alignItems="center"
|
||
spacing={0.5}
|
||
>
|
||
<TextField
|
||
select
|
||
size="small"
|
||
// displayEmpty + a value="" MenuItem lets us
|
||
// render "No sort" when the user hasn't
|
||
// picked a sort column. Without it the
|
||
// empty string would render as a blank
|
||
// field with no label.
|
||
SelectProps={{ displayEmpty: true }}
|
||
value={sortField ?? ""}
|
||
onChange={(e) => {
|
||
const v = e.target.value;
|
||
if (v === "") {
|
||
setSortField(null);
|
||
setSortDir("asc");
|
||
} else if (v !== sortField) {
|
||
setSortField(v);
|
||
// Default to ascending whenever the
|
||
// user picks a new column. The arrow
|
||
// button next to the field flips the
|
||
// direction without re-opening the
|
||
// menu.
|
||
setSortDir("asc");
|
||
}
|
||
setPage(0);
|
||
}}
|
||
aria-label="Sort by"
|
||
sx={{
|
||
minWidth: 132,
|
||
"& .MuiSelect-select": {
|
||
py: 0.5,
|
||
fontSize: 13,
|
||
},
|
||
}}
|
||
>
|
||
<MenuItem
|
||
value=""
|
||
sx={{
|
||
fontStyle: "italic",
|
||
color: "text.secondary",
|
||
}}
|
||
>
|
||
No sort
|
||
</MenuItem>
|
||
{sortableColumns.map((c) => (
|
||
<MenuItem
|
||
key={String(c.key)}
|
||
value={String(c.key)}
|
||
>
|
||
{c.label}
|
||
</MenuItem>
|
||
))}
|
||
</TextField>
|
||
<Tooltip
|
||
title={
|
||
sortDir === "asc"
|
||
? "Ascending — tap to flip"
|
||
: "Descending — tap to flip"
|
||
}
|
||
>
|
||
<span>
|
||
<IconButton
|
||
size="small"
|
||
disabled={!sortField}
|
||
onClick={() =>
|
||
setSortDir((d) =>
|
||
d === "asc" ? "desc" : "asc",
|
||
)
|
||
}
|
||
aria-label="Toggle sort direction"
|
||
sx={{
|
||
width: 32,
|
||
height: 32,
|
||
color: "text.secondary",
|
||
"&:hover": { color: "var(--sb-accent)" },
|
||
}}
|
||
>
|
||
{sortDir === "asc" ? (
|
||
<ArrowUpwardIcon fontSize="small" />
|
||
) : (
|
||
<ArrowDownwardIcon fontSize="small" />
|
||
)}
|
||
</IconButton>
|
||
</span>
|
||
</Tooltip>
|
||
</Stack>
|
||
</Stack>
|
||
)}
|
||
{/* Just the row cards live inside this inner Stack
|
||
so the `paginating` opacity gate hides the data
|
||
cards alone — the select-all + sort toolbar
|
||
above (this branch's mobile equivalent of the
|
||
desktop column headers) stays fully visible
|
||
during the in-flight request. The inner
|
||
`spacing={1.25}` matches the outer Stack's
|
||
spacing so the visible gap between toolbar and
|
||
first card is unchanged. Cards stay mounted
|
||
during the fade so the table card height
|
||
doesn't snap. */}
|
||
<Stack
|
||
spacing={1.25}
|
||
sx={{
|
||
opacity: paginating ? 0 : 1,
|
||
transition:
|
||
"opacity 320ms cubic-bezier(0.45, 0, 0.55, 1)",
|
||
pointerEvents: paginating ? "none" : "auto",
|
||
}}
|
||
>
|
||
{rows.map((row, i) => {
|
||
const key = config.rowKey
|
||
? config.rowKey(row)
|
||
: String(row[config.idKey]);
|
||
return (
|
||
<CrudCard
|
||
key={key}
|
||
rowKey={key}
|
||
row={row}
|
||
index={i}
|
||
columns={columnsRef}
|
||
selected={selected.has(idOf(row))}
|
||
onToggle={toggleRow}
|
||
onEdit={handleEdit}
|
||
onDelete={handleDelete}
|
||
rowActions={config.rowActions}
|
||
onRunAction={handleRunAction}
|
||
/>
|
||
);
|
||
})}
|
||
</Stack>
|
||
</Stack>
|
||
)}
|
||
</Box>
|
||
) : (
|
||
<Table
|
||
size="small"
|
||
// `stickyHeader` keeps the column titles (and the pinned
|
||
// Actions header on the right) visible while the body rows
|
||
// scroll vertically inside the TableContainer — required
|
||
// now that the desktop layout claims a fixed viewport
|
||
// height and scrolls the rows internally rather than
|
||
// letting the page itself scroll. Combines with the
|
||
// existing `ACTIONS_HEADER_CELL_SX` (which already pins
|
||
// the Actions header to the right) to make that one cell
|
||
// sticky on both axes simultaneously.
|
||
stickyHeader
|
||
// Excel-style resizing requires `tableLayout: fixed` so the
|
||
// explicit pixel widths set on the header cells are honoured
|
||
// strictly (otherwise the browser would still grow columns to
|
||
// fit content). Cells without an explicit width fall back to
|
||
// the auto-distributed remainder.
|
||
//
|
||
// `minWidth: tableMinWidth` keeps the table from being
|
||
// squeezed below the sum of column widths — without it, a
|
||
// user-resized column would visually overlap its neighbours
|
||
// when their total exceeded the container's width.
|
||
sx={{ tableLayout: "fixed", minWidth: tableMinWidth }}
|
||
>
|
||
<TableHead>
|
||
<TableRow>
|
||
<TableCell
|
||
sx={{
|
||
// Plain cell (no `padding="checkbox"`) so MUI's
|
||
// hardcoded `width: 48px` and tight 4 px padding don't
|
||
// collide with the resizable-column layout. Centered
|
||
// contents keep the Checkbox visually balanced inside
|
||
// the column regardless of the chosen width.
|
||
width: 72,
|
||
paddingLeft: 0,
|
||
paddingRight: 0,
|
||
textAlign: "center",
|
||
overflow: "visible",
|
||
}}
|
||
>
|
||
<Checkbox
|
||
size="small"
|
||
color="primary"
|
||
indeterminate={someSelected}
|
||
checked={allSelected}
|
||
onChange={toggleAll}
|
||
disabled={rows.length === 0}
|
||
inputProps={{ "aria-label": "select all rows" }}
|
||
/>
|
||
</TableCell>
|
||
{config.columns.map((c, idx) => {
|
||
const sortable = c.sortable ?? true;
|
||
// The last data column has no resize handle at all.
|
||
// Its right edge is the boundary with the trailing
|
||
// filler cell, so dragging it would be perceived as
|
||
// "resizing the spacer" — every adjustment to this
|
||
// column would have to come out of the filler's
|
||
// budget. Skipping the handle keeps that boundary
|
||
// visually inert; the column's width is governed
|
||
// solely by `effectiveWidth` (default + natural
|
||
// minimum), and the filler absorbs whatever space
|
||
// is left over.
|
||
const isLastDataColumn = idx === config.columns.length - 1;
|
||
const key = String(c.key);
|
||
const isActive = sortable && sortField === key;
|
||
const stored = columnWidths[key];
|
||
// Effective width = whatever the user stored, but never
|
||
// narrower than the measured natural minimum. This is
|
||
// what stops a previously-resized cell (or a fresh
|
||
// localStorage value from before the per-column min was
|
||
// enforced) from rendering smaller than its header
|
||
// content — without it, the label would visually spill
|
||
// into the neighbouring column.
|
||
//
|
||
// For unstored columns we still assign an explicit
|
||
// pixel width (DEFAULT_DATA_COL_WIDTH, or a measured
|
||
// natural minimum if it's larger). With every data
|
||
// column carrying an explicit width, the only
|
||
// auto-sized cell in the row is the trailing
|
||
// <td> filler — which is what stops `tableLayout:
|
||
// fixed` from rubber-banding the unstored neighbours
|
||
// when the user resizes one column. The filler
|
||
// soaks up all the leftover horizontal space, and
|
||
// shrinks to 0 when the column sum exceeds the
|
||
// viewport so the table can overflow horizontally.
|
||
const naturalMin = columnNaturalMins[key];
|
||
const effectiveWidth =
|
||
stored != null
|
||
? naturalMin != null
|
||
? Math.max(stored, naturalMin)
|
||
: stored
|
||
: Math.max(naturalMin ?? 0, DEFAULT_DATA_COL_WIDTH);
|
||
return (
|
||
<TableCell
|
||
key={key}
|
||
ref={(el: HTMLTableCellElement | null) => {
|
||
headerCellRefs.current[key] = el;
|
||
}}
|
||
sortDirection={isActive ? sortDir : false}
|
||
sx={{
|
||
// `position: sticky` (instead of the previous
|
||
// `relative`) is required so the data-column
|
||
// header tracks `stickyHeader`'s `top: 0` and
|
||
// stays glued to the top edge while the body
|
||
// rows scroll vertically. Without this override,
|
||
// MUI's `stickyHeader` rule would lose against
|
||
// the cell's own sx — and only the cells that
|
||
// happen to lack an explicit `position`
|
||
// (checkbox, filler, Actions) would end up
|
||
// sticky, which read as "three random columns
|
||
// glued to the top" while the rest of the
|
||
// header scrolled away with the rows.
|
||
//
|
||
// Sticky positioning also creates a containing
|
||
// block for the absolute-positioned
|
||
// `ResizeHandle` child, exactly the way the
|
||
// previous `relative` did, so the resize-grip
|
||
// geometry below is unaffected.
|
||
position: "sticky",
|
||
top: 0,
|
||
// Match the explicit Actions header bg so a
|
||
// body row scrolling under the sticky data
|
||
// headers doesn't show through. The theme's
|
||
// `MuiTableCell.head` rule already paints
|
||
// `--sb-elevated` here, but listing it
|
||
// explicitly keeps this cell's sticky
|
||
// background self-contained against any
|
||
// future theme override.
|
||
backgroundColor: "var(--sb-elevated)",
|
||
width: `${effectiveWidth}px`,
|
||
// Long header / cell content was overflowing the
|
||
// user's chosen column width — clip with ellipsis
|
||
// so the resize stays predictable.
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{/* Inline-flex measurement wrapper — the inner span
|
||
sizes itself to its natural content (label +
|
||
sort caret) regardless of the cell's enforced
|
||
pixel width, so its `getBoundingClientRect()`
|
||
gives us the smallest cell size that wouldn't
|
||
spill into the next column. */}
|
||
<Box
|
||
component="span"
|
||
ref={(el: HTMLElement | null) => {
|
||
headerLabelRefs.current[key] = el;
|
||
}}
|
||
sx={{
|
||
// Crucial: do NOT cap this with `maxWidth: 100%`.
|
||
// The wrapper has to keep its natural content
|
||
// width even when the cell is constrained
|
||
// narrower — otherwise `getBoundingClientRect`
|
||
// would return the cell's clamped width and
|
||
// `naturalMin` would never grow large enough to
|
||
// prevent the column from being resized below
|
||
// its header content. Visually nothing changes:
|
||
// when the cell is wider than the content the
|
||
// wrapper just sits left-aligned, when narrower
|
||
// the cell's `overflow: hidden` clips the
|
||
// overflow exactly as before.
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{sortable ? (
|
||
<TableSortLabel
|
||
active={isActive}
|
||
direction={isActive ? sortDir : "asc"}
|
||
onClick={() => handleSort(key)}
|
||
// Inactive sortable columns get a symmetric
|
||
// up-and-down chevron icon (`UnfoldMore`) so
|
||
// it reads as "this column can be sorted in
|
||
// either direction" without committing to a
|
||
// default arrow. Once a direction is picked
|
||
// the column becomes active and MUI's default
|
||
// `ArrowDownward` icon takes over (rotated to
|
||
// match `sortDir`).
|
||
IconComponent={
|
||
isActive ? undefined : UnfoldMoreIcon
|
||
}
|
||
sx={{
|
||
"& .MuiTableSortLabel-icon": {
|
||
opacity: isActive ? 1 : 0.45,
|
||
// The inactive icon is symmetric, so
|
||
// MUI's direction-based rotation is
|
||
// meaningless for it — pin it at 0deg so
|
||
// the visual never drifts when MUI flips
|
||
// the rotation class on hover / mount.
|
||
transform: isActive
|
||
? undefined
|
||
: "none !important",
|
||
transition:
|
||
"opacity 0.14s ease, transform 0.14s ease",
|
||
},
|
||
"&:hover .MuiTableSortLabel-icon": {
|
||
opacity: isActive ? 1 : 0.75,
|
||
},
|
||
}}
|
||
>
|
||
{c.label}
|
||
</TableSortLabel>
|
||
) : (
|
||
c.label
|
||
)}
|
||
</Box>
|
||
{!isLastDataColumn && (
|
||
<ResizeHandle
|
||
columnKey={key}
|
||
getCurrentWidth={() => getColumnLiveWidth(key)}
|
||
setWidth={setColumnWidth}
|
||
getMinWidth={() => getColumnNaturalMin(key)}
|
||
getMaxWidth={() => getColumnMaxWidth(key)}
|
||
onResizeStart={lockInUnstoredColumnWidths}
|
||
/>
|
||
)}
|
||
</TableCell>
|
||
);
|
||
})}
|
||
{/* Filler cell — the only auto-width cell in the row.
|
||
Under `tableLayout: fixed` it absorbs whatever
|
||
horizontal space is left over after the fixed-
|
||
width columns (checkbox + each data column +
|
||
Actions). When the user resizes a column the
|
||
filler shrinks/grows to compensate so the data
|
||
columns themselves stay pixel-stable. Once the
|
||
sum of the fixed columns exceeds the viewport
|
||
the filler collapses to 0 and the surrounding
|
||
`TableContainer` provides a horizontal scroll. */}
|
||
<TableCell aria-hidden sx={FILLER_CELL_SX} />
|
||
<TableCell
|
||
align="center"
|
||
sx={{
|
||
...ACTIONS_HEADER_CELL_SX,
|
||
width: 92 + (config.rowActions?.length ?? 0) * 32,
|
||
}}
|
||
>
|
||
Actions
|
||
</TableCell>
|
||
</TableRow>
|
||
</TableHead>
|
||
<TableBody
|
||
sx={{
|
||
// Fade just the row body to opacity 0 the moment the
|
||
// user paginates / sorts / filters. The column
|
||
// headers above (TableHead) stay fully visible so the
|
||
// structure of the table remains legible during the
|
||
// round-trip — only the data cells disappear. Rows
|
||
// stay *mounted* (we never call `setRows([])` here)
|
||
// so the table card keeps its current height; once
|
||
// `reload` lands the new rows in `setRows` and flips
|
||
// `paginating` back to false, the body fades back to
|
||
// opacity 1, cross-fading the new entries into the
|
||
// slot the old ones occupied.
|
||
opacity: paginating ? 0 : 1,
|
||
transition: `opacity ${dimDurationMs}ms cubic-bezier(0.45, 0, 0.55, 1)`,
|
||
// Lock out interaction while the body is visually
|
||
// gone — clicking an invisible row would be
|
||
// surprising.
|
||
pointerEvents: paginating ? "none" : "auto",
|
||
}}
|
||
>
|
||
{rows.length === 0 && (
|
||
// Empty-state row is rendered the moment the table mounts
|
||
// so the card reserves the same vertical space the "No
|
||
// data yet" stack will eventually occupy — no collapse-
|
||
// to-header-only bounce, and no layout jump when the
|
||
// first response lands.
|
||
//
|
||
// Before the first reload finishes (`!hasFetched`) the
|
||
// inner stack is kept invisible: the cell still
|
||
// contributes its full height to the row, but the user
|
||
// doesn't see the icon / "No data yet" label / Create
|
||
// button flash before we even know whether there's
|
||
// data. Once `hasFetched` flips to true and `rows` is
|
||
// still empty, the stack becomes visible and the user
|
||
// gets the real empty-state CTA.
|
||
<TableRow
|
||
// The global `MuiTableRow` override in theme.ts paints
|
||
// every row with an `${elevated2} !important` tint on
|
||
// `:hover` so data rows feel responsive to the cursor.
|
||
// The empty-state row isn't a data row — there's
|
||
// nothing to "select" — so that highlight reads as a
|
||
// misleading affordance: the user moves the mouse to
|
||
// click "Create the first one" and the entire band
|
||
// behind the icon / label / button lights up. Pin
|
||
// the hover background to transparent (also using
|
||
// `!important`, since the theme rule does) so the
|
||
// empty state sits on the plain table surface; the
|
||
// small `action.hover`-tinted icon tile below stays
|
||
// exactly as it was — only the surrounding row band
|
||
// stops reacting to hover.
|
||
sx={{
|
||
"&:hover": {
|
||
backgroundColor: "transparent !important",
|
||
},
|
||
}}
|
||
>
|
||
<TableCell
|
||
// Spans every cell in the row: checkbox + each
|
||
// data column + the auto-width filler + Actions.
|
||
colSpan={config.columns.length + 3}
|
||
sx={{ py: 8, px: 0 }}
|
||
>
|
||
<Box
|
||
sx={{
|
||
position: "sticky",
|
||
left: 0,
|
||
width: tableViewportWidth ?? "100%",
|
||
display: "flex",
|
||
justifyContent: "center",
|
||
}}
|
||
>
|
||
<Stack
|
||
alignItems="center"
|
||
spacing={1.5}
|
||
sx={{
|
||
// Cross-fade the empty-state contents in once
|
||
// the first reload finishes; the stack itself
|
||
// stays mounted from initial render so the
|
||
// table card reserves its full height. Using
|
||
// `opacity` (instead of `visibility: hidden`
|
||
// → `visible`) gives a soft entrance that
|
||
// doesn't snap into place the moment the
|
||
// first response lands. `pointerEvents`
|
||
// follow the same gate so taps on the
|
||
// (transparent) Create button can't fire
|
||
// before the empty-state is confirmed.
|
||
//
|
||
// The `!paginating` clause keeps the
|
||
// placeholder hidden while a pagination /
|
||
// sort / filter request is in flight: in
|
||
// the common case the previous rows are
|
||
// still mounted (the surrounding TableBody
|
||
// is faded to opacity 0) and rows.length
|
||
// is non-zero so this branch isn't
|
||
// rendered. The gate matters for the case
|
||
// where the previous page was already
|
||
// empty AND the user paginates / changes a
|
||
// filter — then this branch IS rendered
|
||
// but should stay hidden during the round-
|
||
// trip, not flash "No data yet" mid-fetch.
|
||
opacity: hasFetched && !paginating ? 1 : 0,
|
||
transition:
|
||
"opacity 320ms cubic-bezier(0.22, 0.61, 0.36, 1)",
|
||
pointerEvents:
|
||
hasFetched && !paginating ? "auto" : "none",
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
width: 56,
|
||
height: 56,
|
||
borderRadius: 2.5,
|
||
display: "grid",
|
||
placeItems: "center",
|
||
color: "text.secondary",
|
||
bgcolor: "action.hover",
|
||
border: "1px solid",
|
||
borderColor: "divider",
|
||
}}
|
||
>
|
||
<InboxRoundedIcon />
|
||
</Box>
|
||
<Box sx={{ color: "text.secondary", fontSize: 13 }}>
|
||
No data yet
|
||
</Box>
|
||
<Button
|
||
variant="contained"
|
||
color="primary"
|
||
size="small"
|
||
startIcon={<AddIcon />}
|
||
onClick={openCreate}
|
||
>
|
||
Create the first one
|
||
</Button>
|
||
</Stack>
|
||
</Box>
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
{rows.map((row, i) => {
|
||
const key = config.rowKey
|
||
? config.rowKey(row)
|
||
: String(row[config.idKey]);
|
||
return (
|
||
<CrudRow
|
||
key={key}
|
||
rowKey={key}
|
||
row={row}
|
||
index={i}
|
||
columns={columnsRef}
|
||
selected={selected.has(idOf(row))}
|
||
onToggle={toggleRow}
|
||
onEdit={handleEdit}
|
||
onDelete={handleDelete}
|
||
rowActions={config.rowActions}
|
||
onRunAction={handleRunAction}
|
||
/>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</TableContainer>
|
||
{config.count !== undefined && (
|
||
// The pagination toolbar is rendered from the first paint
|
||
// onwards so the Paper card has a stable total height
|
||
// before the first `list()` + `count()` round-trip
|
||
// resolves. Without that, the bottom edge of the card
|
||
// (and everything below it on the page) visibly jerked
|
||
// by ~48 px the moment the toolbar appeared, which read
|
||
// as a twitch of the table's bottom bar.
|
||
//
|
||
// While we're still waiting on count, `total` is `null`
|
||
// and `total ?? -1` falls through to -1; the
|
||
// `labelDisplayedRows` fallback below converts that into
|
||
// a stable "0–0 of 0" placeholder (same fallback also
|
||
// covers the edge case where `count` errors out but
|
||
// `list()` succeeded). Pre-fetch the toolbar's controls
|
||
// are dimmed and pointer-events disabled so the user
|
||
// can't accidentally change the page or page size before
|
||
// we know what's there.
|
||
<TablePagination
|
||
component="div"
|
||
count={total ?? -1}
|
||
page={page}
|
||
onPageChange={(_, p) => setPage(p)}
|
||
rowsPerPage={pageSize}
|
||
onRowsPerPageChange={(e) => {
|
||
setPageSize(Number(e.target.value));
|
||
setPage(0);
|
||
}}
|
||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||
// Custom range label. MUI's default formatter renders
|
||
// "X–Y of more than Y" whenever `count` is negative —
|
||
// which is what we pass on the *first* render of the
|
||
// page (before /count has resolved, `total` is still
|
||
// `null` and `total ?? -1` falls through to -1). The
|
||
// resulting "of more than 0" / "of more than 25"
|
||
// flashes for a frame on every page reload until the
|
||
// count endpoint replies, which reads as a glitch.
|
||
//
|
||
// While we're waiting on the count we render a stable
|
||
// "0–0 of 0" placeholder — same shape as the eventual
|
||
// real label, so the toolbar's width / arrow positions
|
||
// don't shift the moment the real number arrives. Once
|
||
// the count has resolved, this is identical to MUI's
|
||
// default formatter ("from–to of count") so the
|
||
// user-visible UX is unchanged in the steady state.
|
||
labelDisplayedRows={({ from, to, count }) =>
|
||
count < 0 ? "0–0 of 0" : `${from}–${to} of ${count}`
|
||
}
|
||
sx={{
|
||
borderTop: "1px solid",
|
||
borderColor: "divider",
|
||
".MuiTablePagination-toolbar": { minHeight: 48 },
|
||
// Tabular numerals — every digit advances the same
|
||
// width regardless of glyph. Without this the
|
||
// "X–Y of Z" range reflows as the user paginates
|
||
// (e.g. "1–25 of 100" → "26–50 of 100": the "26"
|
||
// is visibly wider than "1", which pushed the
|
||
// chevron buttons sideways). The property is
|
||
// inherited, so applying it on the root reaches the
|
||
// displayed-rows label and the rows-per-page select
|
||
// value together. Every other text in the toolbar
|
||
// is non-numeric and is unaffected.
|
||
fontVariantNumeric: "tabular-nums",
|
||
// Pre-fetch: dim the toolbar and disable interaction
|
||
// so the placeholder "0–0 of 0" doesn't read as a
|
||
// working control. Cross-fades to full opacity once
|
||
// the first reload finishes, in lockstep with the
|
||
// empty-state CTA / data rows materialising above.
|
||
opacity: hasFetched ? 1 : 0.55,
|
||
pointerEvents: hasFetched ? "auto" : "none",
|
||
transition:
|
||
"opacity 320ms cubic-bezier(0.22, 0.61, 0.36, 1)",
|
||
}}
|
||
/>
|
||
)}
|
||
</Paper>
|
||
{/* Mount the dialog while EITHER `dialogOpen` is true OR the
|
||
underlying creating/editing state still has content — that
|
||
second clause keeps the component alive through the MUI leave
|
||
transition (open=false → onExited fires → handleDialogExited
|
||
clears the state → next render unmounts). Without it the
|
||
dialog would yank itself out of the DOM the moment the user
|
||
clicks Cancel and the close animation would never play. */}
|
||
{(dialogOpen || creating || editing !== null) && (
|
||
<CrudDialog
|
||
open={dialogOpen}
|
||
mode={creating ? "create" : "update"}
|
||
fields={config.fields}
|
||
initial={editing ? (config.fromEntity ? config.fromEntity(editing) : (editing as unknown as Record<string, unknown>)) : undefined}
|
||
title={creating ? `New ${config.title.slice(0, -1)}` : `Edit ${config.title.slice(0, -1)}`}
|
||
onClose={closeDialog}
|
||
onExited={handleDialogExited}
|
||
onSubmit={creating ? handleCreate : handleUpdate}
|
||
/>
|
||
)}
|
||
{/* Delete-confirmation dialog: replaces the previous browser-native
|
||
window.confirm and serves both per-row and bulk deletes.
|
||
Stays a compact pop-over on mobile too (it's tiny), no need for
|
||
the full-screen treatment the form dialog gets. */}
|
||
<Dialog
|
||
open={pendingDelete !== null}
|
||
onClose={() => {
|
||
if (!deleteBusy) setPendingDelete(null);
|
||
}}
|
||
maxWidth="xs"
|
||
fullWidth
|
||
>
|
||
<DialogTitle>
|
||
{displayPendingDelete?.kind === "bulk"
|
||
? `Delete ${displayPendingDelete.ids.length} ${
|
||
displayPendingDelete.ids.length === 1
|
||
? config.title.slice(0, -1).toLowerCase()
|
||
: config.title.toLowerCase()
|
||
}?`
|
||
: `Delete ${config.title.slice(0, -1).toLowerCase()}?`}
|
||
</DialogTitle>
|
||
<DialogContent dividers>
|
||
<Typography variant="body2" color="text.secondary">
|
||
{displayPendingDelete?.kind === "bulk"
|
||
? `This will permanently remove ${displayPendingDelete.ids.length} ${
|
||
displayPendingDelete.ids.length === 1
|
||
? config.title.slice(0, -1).toLowerCase()
|
||
: config.title.toLowerCase()
|
||
} from the database. This action cannot be undone.`
|
||
: `${
|
||
displayPendingDelete?.kind === "single" ? displayPendingDelete.label : ""
|
||
} will be permanently removed from the database. This action cannot be undone.`}
|
||
</Typography>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button
|
||
onClick={() => setPendingDelete(null)}
|
||
disabled={deleteBusy}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
variant="contained"
|
||
color="error"
|
||
startIcon={
|
||
// Cross-fade the trash icon with a small spinner instead
|
||
// of swapping them in a single frame. Both glyphs share
|
||
// the same 18×18 box so the start-icon column doesn't
|
||
// shift width when busy flips.
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
position: "relative",
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: 18,
|
||
height: 18,
|
||
}}
|
||
>
|
||
<DeleteIcon
|
||
fontSize="small"
|
||
sx={{
|
||
position: "absolute",
|
||
opacity: deleteBusy ? 0 : 1,
|
||
transition: "opacity 180ms ease",
|
||
}}
|
||
/>
|
||
<CircularProgress
|
||
size={14}
|
||
thickness={5}
|
||
color="inherit"
|
||
sx={{
|
||
position: "absolute",
|
||
opacity: deleteBusy ? 1 : 0,
|
||
transition: "opacity 180ms ease",
|
||
}}
|
||
/>
|
||
</Box>
|
||
}
|
||
onClick={() => void confirmDelete()}
|
||
disabled={deleteBusy}
|
||
// Keep the red palette while the button is disabled mid-
|
||
// delete instead of MUI's default washed-out gray. The
|
||
// action is in flight, not unavailable — the spinner is
|
||
// already telling the user it's busy, so we don't need
|
||
// a second "this control is dead" signal on top.
|
||
sx={{
|
||
"&.Mui-disabled": {
|
||
bgcolor: "error.main",
|
||
color: "error.contrastText",
|
||
},
|
||
}}
|
||
>
|
||
{/* Cross-fade "Delete" ↔ "Deleting…" while keeping the
|
||
button at a fixed width — the longer label is rendered
|
||
as an invisible spacer so MUI lays the button out
|
||
against it once and the size doesn't animate. */}
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
position: "relative",
|
||
display: "inline-block",
|
||
lineHeight: "inherit",
|
||
}}
|
||
>
|
||
<Box component="span" sx={{ visibility: "hidden" }}>
|
||
Deleting…
|
||
</Box>
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
position: "absolute",
|
||
inset: 0,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
opacity: deleteBusy ? 0 : 1,
|
||
transition: "opacity 180ms ease",
|
||
}}
|
||
>
|
||
Delete
|
||
</Box>
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
position: "absolute",
|
||
inset: 0,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
opacity: deleteBusy ? 1 : 0,
|
||
transition: "opacity 180ms ease",
|
||
}}
|
||
>
|
||
Deleting…
|
||
</Box>
|
||
</Box>
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
{/* Row-action confirmation dialog. Opens whenever a configured
|
||
RowActionSpec.confirm() returns a payload — the per-page
|
||
replacement for `window.confirm` for non-destructive actions
|
||
like "Reset traffic". Same compact size and chrome as the
|
||
Delete dialog above; the start icon, primary colour and
|
||
button labels come from the action / its `confirm` payload
|
||
so the dialog can read as either a primary "Reset" or a
|
||
destructive "Wipe" depending on the spec. */}
|
||
<Dialog
|
||
open={pendingAction !== null}
|
||
onClose={() => {
|
||
if (!actionBusy) setPendingAction(null);
|
||
}}
|
||
maxWidth="xs"
|
||
fullWidth
|
||
>
|
||
<DialogTitle>{displayActionConfirm?.title ?? ""}</DialogTitle>
|
||
<DialogContent dividers>
|
||
<Typography variant="body2" color="text.secondary">
|
||
{displayActionConfirm?.description ?? ""}
|
||
</Typography>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button
|
||
onClick={() => setPendingAction(null)}
|
||
disabled={actionBusy}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
variant="contained"
|
||
color={displayActionConfirm?.color ?? "primary"}
|
||
startIcon={
|
||
// Cross-fade the action's icon with a small spinner so
|
||
// the start-icon column doesn't shift width when the
|
||
// busy flag flips. Mirrors the same pattern used in the
|
||
// Delete dialog above.
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
position: "relative",
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: 18,
|
||
height: 18,
|
||
}}
|
||
>
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
position: "absolute",
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
opacity: actionBusy ? 0 : 1,
|
||
transition: "opacity 180ms ease",
|
||
}}
|
||
>
|
||
{displayPendingAction?.action.icon}
|
||
</Box>
|
||
<CircularProgress
|
||
size={14}
|
||
thickness={5}
|
||
color="inherit"
|
||
sx={{
|
||
position: "absolute",
|
||
opacity: actionBusy ? 1 : 0,
|
||
transition: "opacity 180ms ease",
|
||
}}
|
||
/>
|
||
</Box>
|
||
}
|
||
onClick={() => void confirmAction()}
|
||
disabled={actionBusy}
|
||
// Same disabled-state colour preservation as the Delete
|
||
// button: keep the configured palette so the spinner is
|
||
// the only "I'm busy" signal, not a washed-out grey
|
||
// background on top of it.
|
||
sx={(t) => {
|
||
const palette =
|
||
t.palette[displayActionConfirm?.color ?? "primary"];
|
||
return {
|
||
"&.Mui-disabled": {
|
||
bgcolor: palette.main,
|
||
color: palette.contrastText,
|
||
},
|
||
};
|
||
}}
|
||
>
|
||
{/* Cross-fade idle ↔ busy labels at a fixed width — the
|
||
longer of the two labels acts as the spacer so MUI
|
||
lays the button out against it once and the size
|
||
doesn't animate. */}
|
||
{(() => {
|
||
const idle =
|
||
displayActionConfirm?.confirmLabel ??
|
||
displayPendingAction?.action.label ??
|
||
"Confirm";
|
||
const busy =
|
||
displayActionConfirm?.busyLabel ?? `${idle}…`;
|
||
const spacer = idle.length >= busy.length ? idle : busy;
|
||
return (
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
position: "relative",
|
||
display: "inline-block",
|
||
lineHeight: "inherit",
|
||
}}
|
||
>
|
||
<Box component="span" sx={{ visibility: "hidden" }}>
|
||
{spacer}
|
||
</Box>
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
position: "absolute",
|
||
inset: 0,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
opacity: actionBusy ? 0 : 1,
|
||
transition: "opacity 180ms ease",
|
||
}}
|
||
>
|
||
{idle}
|
||
</Box>
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
position: "absolute",
|
||
inset: 0,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
opacity: actionBusy ? 1 : 0,
|
||
transition: "opacity 180ms ease",
|
||
}}
|
||
>
|
||
{busy}
|
||
</Box>
|
||
</Box>
|
||
);
|
||
})()}
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// CrudRowInner renders a single table row. Wrapped in React.memo with a
|
||
// custom equality fn so typing in a filter / opening dialogs / paging
|
||
// doesn't re-render every visible row through MUI's emotion CSS-in-JS
|
||
// (which was the source of the small <100 ms freezes when pressing
|
||
// buttons or typing). Only `row` data, the `selected` boolean, and the
|
||
// `columns` reference participate in equality — handler refs and any
|
||
// other parent state are intentionally ignored, since the closures
|
||
// always do the same thing.
|
||
interface CrudRowProps<TEntity> {
|
||
row: TEntity;
|
||
rowKey: string;
|
||
columns: ColumnSpec<TEntity>[];
|
||
selected: boolean;
|
||
// Position of the row inside the currently-visible page slice.
|
||
// Drives the staggered entrance animation below — the actual prop
|
||
// is intentionally NOT part of the memo equality check (see
|
||
// `CrudRow` further down) so a row simply changing position
|
||
// (resort / page change with overlap) doesn't re-render via
|
||
// emotion just because its index moved by one.
|
||
index: number;
|
||
onToggle: (row: TEntity) => void;
|
||
onEdit: (row: TEntity) => void;
|
||
onDelete: (row: TEntity) => void;
|
||
// Optional extra actions (rendered before Edit / Delete). Stable
|
||
// reference from the parent — see the memo equality fn below.
|
||
rowActions?: RowActionSpec<TEntity>[];
|
||
onRunAction?: (row: TEntity, action: RowActionSpec<TEntity>) => void;
|
||
}
|
||
function CrudRowInner<TEntity>(props: CrudRowProps<TEntity>) {
|
||
const {
|
||
row,
|
||
rowKey,
|
||
columns,
|
||
selected,
|
||
index,
|
||
onToggle,
|
||
onEdit,
|
||
onDelete,
|
||
rowActions,
|
||
onRunAction,
|
||
} = props;
|
||
// Entrance animation — opacity-only fade so the row reads as
|
||
// "just arrived" when the table fills in (initial load, page
|
||
// change, filter applied, sort change). Driven by the Web
|
||
// Animations API rather than a CSS keyframe rule for the same
|
||
// reason DashboardTile uses WAAPI: emotion's class-hash churn
|
||
// can re-trigger a CSS keyframe on an already-mounted element,
|
||
// which surfaced as a "flash twice on cold load" glitch on the
|
||
// dashboard. The `animatedRef` flag guarantees one play per
|
||
// row fiber lifetime.
|
||
//
|
||
// We deliberately animate *opacity* only. `transform: translateY`
|
||
// would create a containing block on the `<tr>`, which breaks
|
||
// `position: sticky` on the Actions cell (the right-pinned
|
||
// column would stop sticking to the scroll viewport and start
|
||
// sticking to the row instead). Plain opacity has none of
|
||
// those side effects.
|
||
//
|
||
// Stagger delay is `min(index * 22, 220)` ms: the first ~10
|
||
// rows cascade in quick succession, every later row fires at
|
||
// the 220 ms cap so a 100-row page doesn't take 2.2 s to
|
||
// settle. With a 280 ms duration the last staggered row
|
||
// finishes at ~500 ms — under the 600 ms threshold where a
|
||
// table tween starts to feel like waiting.
|
||
//
|
||
// `prefers-reduced-motion` users skip the animation entirely
|
||
// and the row mounts at full opacity from the first frame.
|
||
const rowRef = useRef<HTMLTableRowElement | null>(null);
|
||
const animatedRef = useRef(false);
|
||
useLayoutEffect(() => {
|
||
const el = rowRef.current;
|
||
if (!el || animatedRef.current) return;
|
||
if (typeof el.animate !== "function") return;
|
||
animatedRef.current = true;
|
||
if (
|
||
typeof window !== "undefined" &&
|
||
typeof window.matchMedia === "function" &&
|
||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||
) {
|
||
return;
|
||
}
|
||
el.animate(
|
||
[{ opacity: 0 }, { opacity: 1 }],
|
||
{
|
||
duration: 280,
|
||
delay: Math.min(index * 22, 220),
|
||
easing: "cubic-bezier(0.22, 0.61, 0.36, 1)",
|
||
fill: "backwards",
|
||
},
|
||
);
|
||
// Mount-time animation only — `index` deliberately omitted so
|
||
// a row whose position shifts later doesn't re-trigger.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
return (
|
||
<TableRow hover ref={rowRef}>
|
||
<TableCell
|
||
sx={{
|
||
paddingLeft: 0,
|
||
paddingRight: 0,
|
||
textAlign: "center",
|
||
overflow: "visible",
|
||
}}
|
||
>
|
||
<Checkbox
|
||
size="small"
|
||
color="primary"
|
||
checked={selected}
|
||
onChange={() => onToggle(row)}
|
||
inputProps={{ "aria-label": `select row ${rowKey}` }}
|
||
/>
|
||
</TableCell>
|
||
{columns.map((c) => {
|
||
const isId = c.key === "id" || c.key === "uuid";
|
||
return (
|
||
<TableCell
|
||
key={String(c.key)}
|
||
sx={isId ? ID_CELL_SX : BODY_CELL_SX}
|
||
>
|
||
{c.render
|
||
? c.render(row)
|
||
: renderDefault((row as Record<string, unknown>)[c.key as string])}
|
||
</TableCell>
|
||
);
|
||
})}
|
||
{/* Body filler cell — mirrors the auto-width filler in the
|
||
header so each body row has the same column count and the
|
||
column widths line up. Empty content; padding stripped so
|
||
it can collapse to 0 px under `tableLayout: fixed` when
|
||
the fixed columns exceed the viewport. */}
|
||
<TableCell aria-hidden sx={FILLER_CELL_SX} />
|
||
<TableCell align="center" sx={ACTIONS_CELL_SX}>
|
||
<Stack
|
||
direction="row"
|
||
spacing={0.5}
|
||
justifyContent="center"
|
||
alignItems="center"
|
||
>
|
||
{rowActions?.map((action) => {
|
||
if (action.visible && !action.visible(row)) return null;
|
||
return (
|
||
<Tooltip key={action.key} title={action.label}>
|
||
<IconButton
|
||
size="small"
|
||
aria-label={action.label}
|
||
onClick={() => onRunAction?.(row, action)}
|
||
sx={action.variant === "danger" ? DELETE_BTN_SX : EDIT_BTN_SX}
|
||
>
|
||
{action.icon}
|
||
</IconButton>
|
||
</Tooltip>
|
||
);
|
||
})}
|
||
<Tooltip title="Edit">
|
||
<IconButton size="small" onClick={() => onEdit(row)} sx={EDIT_BTN_SX}>
|
||
<EditIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
<Tooltip title="Delete">
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => onDelete(row)}
|
||
sx={DELETE_BTN_SX}
|
||
>
|
||
<DeleteIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Stack>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
}
|
||
const CrudRow = memo(CrudRowInner, (prev, next) => {
|
||
return (
|
||
prev.row === next.row &&
|
||
prev.selected === next.selected &&
|
||
prev.columns === next.columns &&
|
||
prev.rowKey === next.rowKey &&
|
||
prev.rowActions === next.rowActions
|
||
);
|
||
}) as typeof CrudRowInner;
|
||
|
||
// CrudCardInner — phone-friendly card variant of CrudRow. Same data,
|
||
// same handlers (onToggle / onEdit / onDelete), but laid out as a
|
||
// vertical label/value list inside a bordered Paper-style Box so the
|
||
// row stays fully visible without horizontal scrolling.
|
||
//
|
||
// Memo'd with the same equality fn as CrudRow for the same reason —
|
||
// keep typing in a filter or paging through cheap.
|
||
function CrudCardInner<TEntity>(props: CrudRowProps<TEntity>) {
|
||
const {
|
||
row,
|
||
rowKey,
|
||
columns,
|
||
selected,
|
||
index,
|
||
onToggle,
|
||
onEdit,
|
||
onDelete,
|
||
rowActions,
|
||
onRunAction,
|
||
} = props;
|
||
// Mirror of the CrudRowInner entrance animation but with the
|
||
// upgrade an HTML <Box> permits: a small `translateY` slide
|
||
// alongside the opacity fade. The Box doesn't host a sticky
|
||
// sibling the way a `<tr>` does, so the new containing block
|
||
// a transform creates is harmless here.
|
||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||
const animatedRef = useRef(false);
|
||
useLayoutEffect(() => {
|
||
const el = cardRef.current;
|
||
if (!el || animatedRef.current) return;
|
||
if (typeof el.animate !== "function") return;
|
||
animatedRef.current = true;
|
||
if (
|
||
typeof window !== "undefined" &&
|
||
typeof window.matchMedia === "function" &&
|
||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||
) {
|
||
return;
|
||
}
|
||
el.animate(
|
||
[
|
||
{ opacity: 0, transform: "translateY(8px)" },
|
||
{ opacity: 1, transform: "translateY(0)" },
|
||
],
|
||
{
|
||
duration: 320,
|
||
delay: Math.min(index * 28, 280),
|
||
easing: "cubic-bezier(0.22, 0.61, 0.36, 1)",
|
||
fill: "backwards",
|
||
},
|
||
);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
return (
|
||
<Box
|
||
ref={cardRef}
|
||
sx={{
|
||
border: "1px solid",
|
||
borderColor: selected ? "primary.main" : "divider",
|
||
borderRadius: 1.5,
|
||
bgcolor: "background.paper",
|
||
transition: "border-color 0.18s ease, box-shadow 0.18s ease",
|
||
}}
|
||
>
|
||
{/* Top strip — selection + actions. Compact so the card itself
|
||
stays tight on a small screen, but tap targets are still
|
||
large enough (Checkbox + IconButtons are 30+ px each). */}
|
||
<Stack
|
||
direction="row"
|
||
alignItems="center"
|
||
sx={{
|
||
px: 0.5,
|
||
py: 0.25,
|
||
borderBottom: "1px solid",
|
||
borderColor: "divider",
|
||
}}
|
||
>
|
||
<Checkbox
|
||
size="small"
|
||
color="primary"
|
||
checked={selected}
|
||
onChange={() => onToggle(row)}
|
||
inputProps={{ "aria-label": `select row ${rowKey}` }}
|
||
/>
|
||
<Box sx={{ flexGrow: 1 }} />
|
||
{rowActions?.map((action) => {
|
||
if (action.visible && !action.visible(row)) return null;
|
||
return (
|
||
<Tooltip key={action.key} title={action.label}>
|
||
<IconButton
|
||
size="small"
|
||
aria-label={action.label}
|
||
onClick={() => onRunAction?.(row, action)}
|
||
sx={action.variant === "danger" ? DELETE_BTN_SX : EDIT_BTN_SX}
|
||
>
|
||
{action.icon}
|
||
</IconButton>
|
||
</Tooltip>
|
||
);
|
||
})}
|
||
<Tooltip title="Edit">
|
||
<IconButton size="small" onClick={() => onEdit(row)} sx={EDIT_BTN_SX}>
|
||
<EditIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
<Tooltip title="Delete">
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => onDelete(row)}
|
||
sx={DELETE_BTN_SX}
|
||
>
|
||
<DeleteIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Stack>
|
||
{/* Body — one row per column. Label hugs a fixed-width left
|
||
column so values line up vertically; the value column flexes
|
||
to the available width and wraps on long content
|
||
(uuids, comma-separated IDs, etc.) instead of being clipped
|
||
like the desktop table cell would. */}
|
||
<Stack spacing={1} sx={{ px: 1.5, py: 1.25 }}>
|
||
{columns.map((c) => {
|
||
const isId = c.key === "id" || c.key === "uuid";
|
||
const value = c.render
|
||
? c.render(row)
|
||
: renderDefault((row as Record<string, unknown>)[c.key as string]);
|
||
return (
|
||
<Box
|
||
key={String(c.key)}
|
||
sx={{
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: 1.25,
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
flexShrink: 0,
|
||
width: 96,
|
||
fontSize: 11,
|
||
fontWeight: 600,
|
||
letterSpacing: 0.6,
|
||
textTransform: "uppercase",
|
||
color: "text.secondary",
|
||
pt: 0.25,
|
||
// Wrap long column labels (e.g. "Bandwidth limit")
|
||
// onto two lines instead of forcing a wider label
|
||
// gutter that would steal space from values.
|
||
wordBreak: "break-word",
|
||
}}
|
||
>
|
||
{c.label}
|
||
</Box>
|
||
<Box
|
||
sx={{
|
||
flexGrow: 1,
|
||
minWidth: 0,
|
||
fontSize: 13.5,
|
||
// Allow values to break onto multiple lines on
|
||
// narrow screens. ID-style columns get the
|
||
// monospace treatment to match the table's
|
||
// ID_CELL_SX, but without the ellipsis (the whole
|
||
// value is useful here, and we have room to wrap).
|
||
wordBreak: "break-word",
|
||
...(isId
|
||
? {
|
||
fontFamily:
|
||
'"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
|
||
fontSize: 12.5,
|
||
color: "text.secondary",
|
||
}
|
||
: {}),
|
||
}}
|
||
>
|
||
{value}
|
||
</Box>
|
||
</Box>
|
||
);
|
||
})}
|
||
</Stack>
|
||
</Box>
|
||
);
|
||
}
|
||
const CrudCard = memo(CrudCardInner, (prev, next) => {
|
||
return (
|
||
prev.row === next.row &&
|
||
prev.selected === next.selected &&
|
||
prev.columns === next.columns &&
|
||
prev.rowKey === next.rowKey &&
|
||
prev.rowActions === next.rowActions
|
||
);
|
||
}) as typeof CrudCardInner;
|
||
|
||
// DateCell — pretty two-line date display used by `renderDefault` whenever
|
||
// it sees an ISO datetime. Top row is the day in `15 Jan 2025` form (year
|
||
// dropped if it's the current year so the cell stays compact); bottom row
|
||
// is the time in `HH:mm` form, dimmer and a touch smaller. Hovering shows
|
||
// the full localized stamp plus a relative ("2 hours ago"-style) hint;
|
||
// for entries too old to express compactly the relative half is dropped
|
||
// and only the full stamp is shown.
|
||
function DateCell({ value }: { value: string }) {
|
||
const d = dayjs(value);
|
||
if (!d.isValid()) return <>{value}</>;
|
||
const now = dayjs();
|
||
const sameYear = d.year() === now.year();
|
||
const datePart = sameYear ? d.format("D MMM") : d.format("D MMM YYYY");
|
||
const timePart = d.format("HH:mm");
|
||
const fullStamp = d.format("D MMMM YYYY, HH:mm:ss");
|
||
// Cheap "Xy ago" formatter — avoids pulling in dayjs's relativeTime
|
||
// plugin just for one column. For entries older than 30 days we omit
|
||
// the relative half and let the tooltip show the full stamp on its own.
|
||
const diffMs = now.diff(d);
|
||
const sec = Math.round(diffMs / 1000);
|
||
const min = Math.round(sec / 60);
|
||
const hr = Math.round(min / 60);
|
||
const day = Math.round(hr / 24);
|
||
let relative = "";
|
||
if (Math.abs(sec) < 60) relative = "just now";
|
||
else if (Math.abs(min) < 60) relative = `${min} min ago`;
|
||
else if (Math.abs(hr) < 24) relative = `${hr}h ago`;
|
||
else if (Math.abs(day) < 30) relative = `${day}d ago`;
|
||
const tooltipTitle = relative ? `${fullStamp} · ${relative}` : fullStamp;
|
||
return (
|
||
<Tooltip title={tooltipTitle} placement="top">
|
||
<Box
|
||
sx={{
|
||
display: "inline-flex",
|
||
flexDirection: "column",
|
||
lineHeight: 1.1,
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
<Box component="span" sx={{ fontWeight: 500 }}>
|
||
{datePart}
|
||
</Box>
|
||
<Box
|
||
component="span"
|
||
sx={{ fontSize: 12, color: "text.secondary", mt: 0.25 }}
|
||
>
|
||
{timePart}
|
||
</Box>
|
||
</Box>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
|
||
function renderDefault(v: unknown): ReactNode {
|
||
if (v === null || v === undefined) return "";
|
||
if (Array.isArray(v)) {
|
||
// gap (instead of Stack spacing) gives the chips a real two-axis
|
||
// gutter when the column is narrow enough that the chips have to wrap
|
||
// onto multiple lines — so e.g. `squad_ids` no longer reads as two
|
||
// crammed-together rows of chips when the page is squeezed.
|
||
return (
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
flexWrap: "wrap",
|
||
rowGap: 0.5,
|
||
columnGap: 0.5,
|
||
maxWidth: "100%",
|
||
}}
|
||
>
|
||
{v.map((x, i) => (
|
||
<Chip key={i} label={String(x)} size="small" />
|
||
))}
|
||
</Box>
|
||
);
|
||
}
|
||
if (typeof v === "object") return JSON.stringify(v);
|
||
if (typeof v === "string" && /\d{4}-\d{2}-\d{2}T/.test(v)) {
|
||
return <DateCell value={v} />;
|
||
}
|
||
return String(v);
|
||
}
|
||
|
||
interface DialogProps {
|
||
// Controls MUI's `open` directly so the parent can flip it to false to
|
||
// trigger the leave transition while the component stays mounted; the
|
||
// parent then waits for `onExited` before clearing its own state.
|
||
open: boolean;
|
||
mode: "create" | "update";
|
||
fields: FieldSpec[];
|
||
initial?: Record<string, unknown>;
|
||
title: string;
|
||
onClose: () => void;
|
||
onExited?: () => void;
|
||
onSubmit: (form: Record<string, unknown>) => Promise<void>;
|
||
}
|
||
|
||
function CrudDialog({
|
||
open,
|
||
mode,
|
||
fields,
|
||
initial,
|
||
title,
|
||
onClose,
|
||
onExited,
|
||
onSubmit,
|
||
}: DialogProps) {
|
||
// Mirror the mobile detection used in CrudPage so the create / edit
|
||
// form dialog goes full-screen on phones + portrait tablets — with a
|
||
// narrow viewport a width-sm modal covers most of the screen anyway,
|
||
// but full-screen gives the long squad / user forms room to breathe
|
||
// and avoids awkward double-scroll (modal scroll + page scroll).
|
||
const dialogTheme = useTheme();
|
||
const dialogIsMobile = useMediaQuery(dialogTheme.breakpoints.down("md"));
|
||
const [form, setForm] = useState<Record<string, unknown>>(() => {
|
||
const base = emptyForm(fields, mode);
|
||
if (initial) {
|
||
// Seed every initial value, including those for fields that are
|
||
// currently hidden (e.g. `type` on the user edit form). Hidden values
|
||
// are still read by `visibleWhen` predicates and dropped at submit
|
||
// time by `normalizeFormForSubmit`.
|
||
for (const f of fields) {
|
||
const v = initial[f.name];
|
||
if (v === undefined) continue;
|
||
if (f.type === "ids" && Array.isArray(v)) base[f.name] = v.join(",");
|
||
else if (f.type === "multiselect" && Array.isArray(v)) {
|
||
// The multi-select stores values as strings (so `Squad.id` numbers
|
||
// become "1", "2"…); coerce here so editing pre-selects the right
|
||
// entries and the submit step can re-parse them.
|
||
base[f.name] = (v as unknown[]).map(String);
|
||
} else base[f.name] = v as unknown;
|
||
}
|
||
}
|
||
return base;
|
||
});
|
||
// Re-evaluate visibility every render so dynamic `visibleWhen` predicates
|
||
// reflect the latest form values (e.g. switching the user `type`).
|
||
const visibleFields = useMemo(
|
||
() => fields.filter((f) => fieldVisible(f, mode, form)),
|
||
[fields, mode, form],
|
||
);
|
||
|
||
// Async option loaders are fired once when the dialog mounts. Each entry
|
||
// overrides the field's static `options` while rendering. Loaders are
|
||
// skipped for fields hidden in the current mode (e.g. a create-only
|
||
// squad picker when the dialog was opened for edit) so the API isn't
|
||
// hit for options no one will see.
|
||
type LoadedOpts = Record<string, { value: string; label?: string }[]>;
|
||
const [loadedOptions, setLoadedOptions] = useState<LoadedOpts>({});
|
||
useEffect(() => {
|
||
const targets = fields.filter(
|
||
(f) =>
|
||
typeof f.optionsLoader === "function" && (!f.only || f.only === mode),
|
||
);
|
||
if (targets.length === 0) return;
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const entries = await Promise.all(
|
||
targets.map(async (f) => {
|
||
const opts = await f.optionsLoader!();
|
||
return [f.name, opts] as const;
|
||
}),
|
||
);
|
||
if (cancelled) return;
|
||
const next: LoadedOpts = {};
|
||
for (const [name, opts] of entries) next[name] = opts;
|
||
setLoadedOptions(next);
|
||
} catch {
|
||
/* loaders are best-effort; fall back to whatever static options exist */
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
// Run only once per dialog instance.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
const optionsFor = (f: FieldSpec) => loadedOptions[f.name] ?? f.options ?? [];
|
||
const [busy, setBusy] = useState(false);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
// Per-field validation messages. A non-empty entry causes the field to
|
||
// render in error state with the message as helper text. Cleared as soon
|
||
// as the user provides a value for the offending field.
|
||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||
|
||
// set updates a single field's value. If the field declares `clears`, every
|
||
// listed dependent field is reset to its empty default at the same time —
|
||
// this is what "switching the user type clears the credential fields"
|
||
// relies on.
|
||
const set = (name: string, value: unknown) => {
|
||
setForm((p) => {
|
||
const next: Record<string, unknown> = { ...p, [name]: value };
|
||
const source = fields.find((f) => f.name === name);
|
||
if (source?.clears && source.clears.length > 0) {
|
||
for (const target of source.clears) {
|
||
const f = fields.find((x) => x.name === target);
|
||
next[target] = f ? emptyValueForField(f) : "";
|
||
}
|
||
}
|
||
return next;
|
||
});
|
||
// Clear the error for the field as soon as the user touches it; also
|
||
// clear any errors on dependent fields that just got reset.
|
||
setFieldErrors((prev) => {
|
||
if (!prev[name] && !fields.find((f) => f.name === name)?.clears) return prev;
|
||
const next = { ...prev };
|
||
delete next[name];
|
||
const source = fields.find((f) => f.name === name);
|
||
if (source?.clears) for (const t of source.clears) delete next[t];
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const submit = async () => {
|
||
// Validate visible required fields first. If anything is missing we
|
||
// surface it inline (per-field) and via a top-level alert, and skip the
|
||
// network round-trip entirely.
|
||
const errors = validateRequired(visibleFields, form);
|
||
if (Object.keys(errors).length > 0) {
|
||
setFieldErrors(errors);
|
||
const missing = visibleFields
|
||
.filter((f) => errors[f.name])
|
||
.map((f) => f.label);
|
||
setErr(`Please fill in the required field${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`);
|
||
return;
|
||
}
|
||
setFieldErrors({});
|
||
setBusy(true);
|
||
setErr(null);
|
||
try {
|
||
await onSubmit(normalizeFormForSubmit(fields, mode, form));
|
||
} catch {
|
||
// Server-side failures are surfaced via the global toast stack
|
||
// by the parent's `notifyApiError` call inside handleCreate /
|
||
// handleUpdate. The dialog stays open (we never advance past
|
||
// the throw) so the user can retry without re-typing the form.
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
// submitOnEnter forwards a top-level Enter key inside the dialog to the
|
||
// submit handler. We deliberately ignore Enter coming from elements that
|
||
// own their own keyboard semantics:
|
||
//
|
||
// - <textarea> (would lose newline support)
|
||
// - the buttons themselves (they invoke their own click handler)
|
||
// - any element inside a portal — open <Select> menus and the
|
||
// DateTimePicker popover render outside this Dialog node, so their
|
||
// Enter never reaches us in the first place.
|
||
const submitOnEnter = (e: KeyboardEvent<HTMLDivElement>) => {
|
||
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing) return;
|
||
const target = e.target as HTMLElement;
|
||
const tag = target.tagName;
|
||
if (tag === "TEXTAREA" || tag === "BUTTON" || tag === "A") return;
|
||
e.preventDefault();
|
||
void submit();
|
||
};
|
||
|
||
return (
|
||
<Dialog
|
||
open={open}
|
||
onClose={onClose}
|
||
fullWidth
|
||
maxWidth="sm"
|
||
fullScreen={dialogIsMobile}
|
||
onKeyDown={submitOnEnter}
|
||
// MUI's default Fade transition runs both directions, but the dialog
|
||
// used to unmount the moment `open` flipped to false (parent gated
|
||
// rendering on `dialogOpen`), so the leave half never had a chance.
|
||
// The parent now keeps us mounted until this callback fires; once
|
||
// it does, it's safe to drop the form state and unmount us.
|
||
TransitionProps={{ onExited }}
|
||
>
|
||
<DialogTitle>{title}</DialogTitle>
|
||
<DialogContent dividers>
|
||
{/* Inline alert reserved for client-side validation summaries
|
||
("Please fill in the required field…"): server errors are
|
||
announced via the global toast stack instead, so the dialog
|
||
never grows a red bar at the top in response to a failed
|
||
CRUD round-trip. */}
|
||
{err && (
|
||
<Alert severity="error" sx={{ mb: 2 }}>
|
||
{err}
|
||
</Alert>
|
||
)}
|
||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||
{visibleFields.map((f) => {
|
||
const value = form[f.name];
|
||
// Error / helper-text wiring shared across every field type:
|
||
// when the field has a validation error we show it in MUI's
|
||
// error state and override the helper text; otherwise the
|
||
// configured helperText (if any) is shown.
|
||
const fieldErr = fieldErrors[f.name];
|
||
const errored = !!fieldErr;
|
||
if (f.type === "select") {
|
||
return (
|
||
<TextField
|
||
key={f.name}
|
||
select
|
||
fullWidth
|
||
size="small"
|
||
required={f.required}
|
||
label={f.label}
|
||
error={errored}
|
||
helperText={fieldErr ?? f.helperText}
|
||
value={(value as string) ?? ""}
|
||
onChange={(e) => set(f.name, e.target.value)}
|
||
>
|
||
{/* Optional fields keep an explicit "(none)" choice so the
|
||
user can clear them. Required fields skip it — there is
|
||
always a real selection. */}
|
||
{!f.required && (
|
||
<MenuItem value="">
|
||
<em>(none)</em>
|
||
</MenuItem>
|
||
)}
|
||
{(optionsFor(f)).map((o) => (
|
||
<MenuItem key={o.value} value={o.value}>
|
||
{o.label ?? o.value}
|
||
</MenuItem>
|
||
))}
|
||
</TextField>
|
||
);
|
||
}
|
||
if (f.type === "multiselect") {
|
||
const arr = Array.isArray(value) ? (value as string[]) : [];
|
||
const opts = optionsFor(f);
|
||
const labelOf = (v: string) =>
|
||
opts.find((o) => o.value === v)?.label ?? v;
|
||
return (
|
||
<TextField
|
||
key={f.name}
|
||
select
|
||
fullWidth
|
||
size="small"
|
||
required={f.required}
|
||
label={f.label}
|
||
error={errored}
|
||
helperText={fieldErr ?? f.helperText}
|
||
SelectProps={{
|
||
multiple: true,
|
||
value: arr,
|
||
onChange: (e) => set(f.name, e.target.value as string[]),
|
||
// Two display modes:
|
||
// - default chips, one per selection (no implied
|
||
// ordering between them);
|
||
// - chain mode (`displayAsChain: true`): the same
|
||
// chips but joined by `→` arrows so an ordered
|
||
// pipeline like flow_keys reads as
|
||
// "User → Destination → IP".
|
||
renderValue: (selected) => {
|
||
const values = selected as string[];
|
||
if (f.displayAsChain) {
|
||
return (
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
flexWrap: "wrap",
|
||
alignItems: "center",
|
||
gap: 0.5,
|
||
}}
|
||
>
|
||
{values.map((v, i) => (
|
||
<Box
|
||
key={v}
|
||
sx={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 0.5,
|
||
}}
|
||
>
|
||
{i > 0 && (
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
color: "text.secondary",
|
||
fontSize: 12,
|
||
userSelect: "none",
|
||
}}
|
||
>
|
||
→
|
||
</Box>
|
||
)}
|
||
<Chip
|
||
label={labelOf(v)}
|
||
size="small"
|
||
color="primary"
|
||
sx={{ height: 22 }}
|
||
/>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
);
|
||
}
|
||
return (
|
||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
||
{values.map((v) => (
|
||
<Chip
|
||
key={v}
|
||
label={labelOf(v)}
|
||
size="small"
|
||
color="primary"
|
||
sx={{ height: 22 }}
|
||
/>
|
||
))}
|
||
</Box>
|
||
);
|
||
},
|
||
}}
|
||
value={arr}
|
||
>
|
||
{opts.map((o) => (
|
||
<MenuItem key={o.value} value={o.value}>
|
||
{o.label ?? o.value}
|
||
</MenuItem>
|
||
))}
|
||
</TextField>
|
||
);
|
||
}
|
||
if (f.type === "uuid") {
|
||
return (
|
||
<TextField
|
||
key={f.name}
|
||
fullWidth
|
||
size="small"
|
||
required={f.required}
|
||
label={f.label}
|
||
error={errored}
|
||
helperText={fieldErr ?? f.helperText}
|
||
value={(value as string | undefined) ?? ""}
|
||
onChange={(e) => set(f.name, e.target.value)}
|
||
InputProps={{
|
||
endAdornment: (
|
||
<Tooltip title="Generate UUID">
|
||
<IconButton
|
||
size="small"
|
||
edge="end"
|
||
onClick={() => set(f.name, generateUUID())}
|
||
>
|
||
<AutorenewIcon fontSize="small" />
|
||
</IconButton>
|
||
</Tooltip>
|
||
),
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
if (f.type === "string-list") {
|
||
const arr = Array.isArray(value) ? (value as string[]) : [];
|
||
return (
|
||
<Box key={f.name} sx={{ mt: -1 }}>
|
||
<Typography variant="caption" color={errored ? "error" : "textSecondary"} sx={{ mb: 0.5, ml: "14px", display: "block" }}>
|
||
{f.label}{f.required ? " *" : ""}
|
||
</Typography>
|
||
<Stack spacing={1}>
|
||
{arr.map((item, idx) => (
|
||
<Stack key={idx} direction="row" spacing={1} alignItems="center">
|
||
<TextField
|
||
fullWidth
|
||
size="small"
|
||
value={item}
|
||
placeholder={`Key ${idx + 1}`}
|
||
onChange={(e) => {
|
||
const next = [...arr];
|
||
next[idx] = e.target.value;
|
||
set(f.name, next);
|
||
}}
|
||
/>
|
||
<IconButton size="small" color="error" onClick={() => set(f.name, arr.filter((_, i) => i !== idx))}>
|
||
<DeleteIcon fontSize="small" />
|
||
</IconButton>
|
||
</Stack>
|
||
))}
|
||
</Stack>
|
||
<Button size="small" startIcon={<AddIcon />} sx={{ mt: 1 }} onClick={() => set(f.name, [...arr, ""])}>
|
||
Add
|
||
</Button>
|
||
{fieldErr && <Typography variant="caption" color="error" sx={{ mt: 0.5, display: "block" }}>{fieldErr}</Typography>}
|
||
</Box>
|
||
);
|
||
}
|
||
const isNumber = f.type === "number";
|
||
// Numeric value of the current cell. Falls back to 0 for the
|
||
// empty state so the up-arrow always has a sensible base to
|
||
// increment from. Decrement is clamped at 0 because the
|
||
// input pattern only accepts non-negative integers anyway.
|
||
const numericValue = isNumber
|
||
? (() => {
|
||
const v = (value as string | number | undefined) ?? "";
|
||
const n = parseInt(String(v), 10);
|
||
return Number.isFinite(n) ? n : 0;
|
||
})()
|
||
: 0;
|
||
const stepValue = (delta: number) => {
|
||
const next = Math.max(0, numericValue + delta);
|
||
set(f.name, String(next));
|
||
};
|
||
return (
|
||
<TextField
|
||
key={f.name}
|
||
fullWidth
|
||
size="small"
|
||
required={f.required}
|
||
label={f.label}
|
||
error={errored}
|
||
helperText={
|
||
fieldErr ??
|
||
f.helperText ??
|
||
(f.type === "ids" ? "Comma-separated IDs" : undefined)
|
||
}
|
||
// Number fields render as plain `text` (rather than the
|
||
// native `type="number"`) so we can fully control which
|
||
// characters are accepted: HTML5 number inputs still let
|
||
// through "e", "+", "-", scientific notation and even some
|
||
// localized separators on certain locales. We strip every
|
||
// non-digit on input and on paste, and block letter keys
|
||
// before they reach the value at all.
|
||
type="text"
|
||
inputMode={isNumber ? "numeric" : undefined}
|
||
// The keyboard / paste handlers go through `inputProps` so
|
||
// they attach directly to the underlying <input> element.
|
||
// Putting `onKeyDown` on the TextField itself isn't always
|
||
// forwarded to the inner input in MUI v6 — this is the
|
||
// reliable spot.
|
||
inputProps={
|
||
isNumber
|
||
? {
|
||
pattern: "[0-9]*",
|
||
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
// ArrowUp / ArrowDown step the value by 1 even
|
||
// though we render as `type="text"` (where the
|
||
// browser would otherwise just move the caret).
|
||
// This matches the behaviour users expect from
|
||
// a numeric field with spinner arrows.
|
||
if (e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
stepValue(1);
|
||
return;
|
||
}
|
||
if (e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
stepValue(-1);
|
||
return;
|
||
}
|
||
// Allow Backspace, Tab, Enter, Home, End,
|
||
// Delete (key.length > 1) and any combo with
|
||
// a modifier (Ctrl+A / Cmd+V / etc.). Block
|
||
// every other single-character key that isn't
|
||
// 0-9.
|
||
if (e.key.length > 1) return;
|
||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||
if (!/^[0-9]$/.test(e.key)) e.preventDefault();
|
||
},
|
||
onPaste: (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||
// Strip non-digits from clipboard text before it
|
||
// reaches the input. Without this the onChange
|
||
// sanitiser would still clean it, but the input
|
||
// would briefly flash the raw paste contents.
|
||
const txt = e.clipboardData.getData("text");
|
||
const cleaned = txt.replace(/\D+/g, "");
|
||
if (cleaned !== txt) {
|
||
e.preventDefault();
|
||
const target = e.currentTarget;
|
||
const start = target.selectionStart ?? target.value.length;
|
||
const end = target.selectionEnd ?? target.value.length;
|
||
const next =
|
||
target.value.slice(0, start) +
|
||
cleaned +
|
||
target.value.slice(end);
|
||
set(f.name, next);
|
||
}
|
||
},
|
||
}
|
||
: undefined
|
||
}
|
||
// Spinner arrows for numeric fields. Two stacked
|
||
// IconButtons sized to fit the small TextField height
|
||
// (~32 px) — 14 px each, 0 px padding — with the up
|
||
// arrow on top and the down arrow underneath. They
|
||
// share the `var(--sb-accent)` hover treatment used
|
||
// elsewhere in the panel.
|
||
InputProps={
|
||
isNumber
|
||
? {
|
||
endAdornment: (
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "stretch",
|
||
ml: 0.5,
|
||
mr: -0.75,
|
||
}}
|
||
>
|
||
<IconButton
|
||
aria-label={`Increase ${f.label}`}
|
||
tabIndex={-1}
|
||
onClick={() => stepValue(1)}
|
||
sx={{
|
||
width: 22,
|
||
height: 14,
|
||
p: 0,
|
||
borderRadius: 0.75,
|
||
color: "text.secondary",
|
||
transition:
|
||
"color 0.16s ease, background-color 0.16s ease",
|
||
"&:hover": { color: "var(--sb-accent)" },
|
||
}}
|
||
>
|
||
<KeyboardArrowUpIcon sx={{ fontSize: 16 }} />
|
||
</IconButton>
|
||
<IconButton
|
||
aria-label={`Decrease ${f.label}`}
|
||
tabIndex={-1}
|
||
disabled={numericValue <= 0}
|
||
onClick={() => stepValue(-1)}
|
||
sx={{
|
||
width: 22,
|
||
height: 14,
|
||
p: 0,
|
||
borderRadius: 0.75,
|
||
color: "text.secondary",
|
||
transition:
|
||
"color 0.16s ease, background-color 0.16s ease",
|
||
"&:hover": { color: "var(--sb-accent)" },
|
||
}}
|
||
>
|
||
<KeyboardArrowDownIcon sx={{ fontSize: 16 }} />
|
||
</IconButton>
|
||
</Box>
|
||
),
|
||
}
|
||
: undefined
|
||
}
|
||
value={(value as string | number | undefined) ?? ""}
|
||
onChange={(e) => {
|
||
const v = isNumber
|
||
? e.target.value.replace(/\D+/g, "")
|
||
: e.target.value;
|
||
set(f.name, v);
|
||
}}
|
||
/>
|
||
);
|
||
})}
|
||
</Stack>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={onClose} disabled={busy}>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
variant="contained"
|
||
onClick={() => void submit()}
|
||
disabled={busy}
|
||
// Same treatment as the delete dialog's primary action: keep
|
||
// the active palette while disabled mid-submit so the spinner
|
||
// is the only "I'm working" cue, instead of layering a washed-
|
||
// out gray on top of it.
|
||
sx={{
|
||
"&.Mui-disabled": {
|
||
bgcolor: "primary.main",
|
||
color: "primary.contrastText",
|
||
},
|
||
}}
|
||
>
|
||
{/* Cross-fade idle ("Create" / "Save") with the busy state
|
||
("[spinner] Saving…") at a fixed button width — the busy
|
||
version is rendered as an invisible spacer once so MUI
|
||
lays the button out against it and the size doesn't
|
||
animate as `busy` flips. */}
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
position: "relative",
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
lineHeight: "inherit",
|
||
}}
|
||
>
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
visibility: "hidden",
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
<Box component="span" sx={{ width: 14, height: 14, mr: 1 }} />
|
||
Saving…
|
||
</Box>
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
position: "absolute",
|
||
inset: 0,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
opacity: busy ? 0 : 1,
|
||
transition: "opacity 180ms ease",
|
||
}}
|
||
>
|
||
{mode === "create" ? "Create" : "Save"}
|
||
</Box>
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
position: "absolute",
|
||
inset: 0,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
opacity: busy ? 1 : 0,
|
||
transition: "opacity 180ms ease",
|
||
}}
|
||
>
|
||
<CircularProgress
|
||
size={14}
|
||
thickness={5}
|
||
color="inherit"
|
||
sx={{ mr: 1 }}
|
||
/>
|
||
Saving…
|
||
</Box>
|
||
</Box>
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// FilterField renders a single filter input based on its spec type. Kept
|
||
// inside this file because the filter model is tightly coupled to
|
||
// `FilterSpec` and the surrounding CrudPage.
|
||
function FilterField({
|
||
spec,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
spec: FilterSpec;
|
||
value: unknown;
|
||
onChange: (v: unknown) => void;
|
||
}) {
|
||
if (spec.type === "select") {
|
||
return (
|
||
<TextField
|
||
select
|
||
fullWidth
|
||
size="small"
|
||
label={spec.label}
|
||
value={(value as string) ?? ""}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
>
|
||
<MenuItem value="">
|
||
<em>(any)</em>
|
||
</MenuItem>
|
||
{(spec.options ?? []).map((o) => (
|
||
<MenuItem key={o.value} value={o.value}>
|
||
{o.label ?? o.value}
|
||
</MenuItem>
|
||
))}
|
||
</TextField>
|
||
);
|
||
}
|
||
if (spec.type === "datetime-range") {
|
||
return <DateRangeFilterField spec={spec} value={value} onChange={onChange} />;
|
||
}
|
||
return (
|
||
<TextField
|
||
fullWidth
|
||
size="small"
|
||
label={spec.label}
|
||
placeholder={spec.placeholder}
|
||
value={(value as string) ?? ""}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// DateRangeFilterField — the datetime-range filter as a single bordered
|
||
// box that visually reads like one "calendar button" filling the whole
|
||
// cell. Two halves (start / end) are themselves clickable: clicking
|
||
// either half opens its DateTimePicker calendar in a popper. The picker
|
||
// is rendered headless (its field replaced via `slots.field`) so MUI X
|
||
// doesn't draw any input, format mask, or `YYYY-MM-DD` placeholder
|
||
// inside the cell.
|
||
function DateRangeFilterField({
|
||
spec,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
spec: FilterSpec;
|
||
value: unknown;
|
||
onChange: (v: unknown) => void;
|
||
}) {
|
||
const r = (value ?? {}) as { start?: string; end?: string };
|
||
const toDayjs = (s: string | undefined): Dayjs | null => {
|
||
if (!s) return null;
|
||
const d = dayjs(s);
|
||
return d.isValid() ? d : null;
|
||
};
|
||
const fromDayjs = (d: Dayjs | null): string | undefined =>
|
||
d && d.isValid() ? d.format("YYYY-MM-DDTHH:mm") : undefined;
|
||
const formatDisplay = (s: string | undefined): string | null => {
|
||
const d = toDayjs(s);
|
||
return d ? d.format("YYYY-MM-DD HH:mm") : null;
|
||
};
|
||
const [openStart, setOpenStart] = useState(false);
|
||
const [openEnd, setOpenEnd] = useState(false);
|
||
// Anchors used by the picker popper. We point them at the visible
|
||
// half-buttons (instead of the headless field) so the calendar opens
|
||
// attached to whichever side the user clicked.
|
||
const startAnchorRef = useRef<HTMLElement | null>(null);
|
||
const endAnchorRef = useRef<HTMLElement | null>(null);
|
||
const halfSx = {
|
||
flex: 1,
|
||
minWidth: 0,
|
||
height: "100%",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
// Centre the [calendar icon + date text] group horizontally
|
||
// inside each half so the value reads as visually centred
|
||
// rather than left-aligned against the inner edge of the cell.
|
||
// The inner text span loses its `flex: 1` (see below) so the
|
||
// group keeps its natural width and `justifyContent: center`
|
||
// can actually move it.
|
||
justifyContent: "center",
|
||
gap: 0.75,
|
||
paddingInline: "10px",
|
||
cursor: "pointer",
|
||
fontSize: 13.5,
|
||
overflow: "hidden",
|
||
userSelect: "none" as const,
|
||
};
|
||
const startDisplay = formatDisplay(r.start);
|
||
const endDisplay = formatDisplay(r.end);
|
||
return (
|
||
<Box
|
||
sx={(t) => ({
|
||
position: "relative",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
width: "100%",
|
||
height: 40,
|
||
borderRadius: `${t.shape.borderRadius}px`,
|
||
border: `1px solid ${t.palette.divider}`,
|
||
backgroundColor:
|
||
t.palette.mode === "light" ? "#ffffff" : "rgba(255,255,255,0.02)",
|
||
transition: "border-color 0.16s ease, box-shadow 0.16s ease",
|
||
"&:hover": { borderColor: t.palette.text.secondary },
|
||
"&:focus-within": {
|
||
borderColor: "var(--sb-accent)",
|
||
boxShadow: `0 0 0 1px var(--sb-accent)`,
|
||
},
|
||
})}
|
||
>
|
||
<Box
|
||
ref={(el: HTMLElement | null) => {
|
||
startAnchorRef.current = el;
|
||
}}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => setOpenStart(true)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") setOpenStart(true);
|
||
}}
|
||
sx={(t) => ({
|
||
...halfSx,
|
||
color: startDisplay ? t.palette.text.primary : t.palette.text.secondary,
|
||
})}
|
||
>
|
||
<CalendarTodayIcon
|
||
sx={{ fontSize: 16, opacity: 0.7, flexShrink: 0 }}
|
||
/>
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
// No `flex: 1` — keep the span at its content width so
|
||
// the parent's `justifyContent: center` actually centres
|
||
// the [icon + date] group instead of stretching the span
|
||
// to fill the half. `minWidth: 0` still lets the span
|
||
// shrink (with ellipsis) if the date string is wider
|
||
// than the available space.
|
||
minWidth: 0,
|
||
}}
|
||
>
|
||
{startDisplay ?? `${spec.label} from`}
|
||
</Box>
|
||
</Box>
|
||
<Box
|
||
sx={(t) => ({
|
||
flex: "0 0 auto",
|
||
color: t.palette.text.secondary,
|
||
fontSize: 13,
|
||
userSelect: "none",
|
||
paddingInline: "4px",
|
||
})}
|
||
>
|
||
—
|
||
</Box>
|
||
<Box
|
||
ref={(el: HTMLElement | null) => {
|
||
endAnchorRef.current = el;
|
||
}}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => setOpenEnd(true)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") setOpenEnd(true);
|
||
}}
|
||
sx={(t) => ({
|
||
...halfSx,
|
||
color: endDisplay ? t.palette.text.primary : t.palette.text.secondary,
|
||
})}
|
||
>
|
||
<CalendarTodayIcon
|
||
sx={{ fontSize: 16, opacity: 0.7, flexShrink: 0 }}
|
||
/>
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
// See the matching comment on the `start` half above —
|
||
// the span is intentionally content-width so the parent
|
||
// can centre the [icon + date] pair.
|
||
minWidth: 0,
|
||
}}
|
||
>
|
||
{endDisplay ?? `${spec.label} to`}
|
||
</Box>
|
||
</Box>
|
||
{/* Headless pickers — their fields are stripped via the `field`
|
||
slot so they exist purely to render the calendar popper when
|
||
opened. The popper is explicitly anchored to whichever
|
||
half-button is active so the calendar lines up under it. */}
|
||
<DateTimePicker
|
||
open={openStart}
|
||
onClose={() => setOpenStart(false)}
|
||
value={toDayjs(r.start)}
|
||
onChange={(d) => onChange({ ...r, start: fromDayjs(d) })}
|
||
ampm={false}
|
||
format="YYYY-MM-DD HH:mm"
|
||
slots={{ field: HiddenField }}
|
||
slotProps={{
|
||
popper: { anchorEl: () => startAnchorRef.current ?? document.body },
|
||
}}
|
||
/>
|
||
<DateTimePicker
|
||
open={openEnd}
|
||
onClose={() => setOpenEnd(false)}
|
||
value={toDayjs(r.end)}
|
||
onChange={(d) => onChange({ ...r, end: fromDayjs(d) })}
|
||
ampm={false}
|
||
format="YYYY-MM-DD HH:mm"
|
||
slots={{ field: HiddenField }}
|
||
slotProps={{
|
||
popper: { anchorEl: () => endAnchorRef.current ?? document.body },
|
||
}}
|
||
/>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// HiddenField — a no-op replacement for MUI X's default field. We still
|
||
// `forwardRef` so the picker's internal ref-pinning logic gets a real
|
||
// node (the picker would warn / fail to render otherwise). The element
|
||
// itself takes zero layout space, the popper anchors to our explicit
|
||
// half-button refs.
|
||
const HiddenField = forwardRef<HTMLSpanElement>(function HiddenField(_, ref) {
|
||
return <span ref={ref} style={{ display: "none" }} aria-hidden="true" />;
|
||
});
|