Files
khata-ui/react-openapi/src/components/ResourceList.tsx

408 lines
14 KiB
TypeScript

import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Typography,
Button,
IconButton,
Tooltip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
Paper,
TableSortLabel,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import VisibilityIcon from "@mui/icons-material/Visibility";
import type { ResourceConfig, FieldConfig } from "../types";
import { useResource } from "../context/useResource";
import { useAppContext } from "../context/AppContext";
import { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./fields";
import { FilterBar } from "./FilterBar";
import { readSseCache, appendSseCache, clearSseCache, nextSseSeq, setSseConnected } from "../context/useResource";
import { SseConnectionStatus } from "./SseConnectionStatus";
interface ResourceListProps {
resource: ResourceConfig;
basePath: string;
}
function matchRow(row: any, filters: Record<string, string>, fields: FieldConfig[], allResources: ResourceConfig[]): boolean {
for (const field of fields) {
if (!field.filterable) continue;
const isRange = field.type === "integer" || field.type === "number" || field.format === "date" || field.format === "date-time";
if (isRange) {
const from = filters[field.name + "_from"];
const to = filters[field.name + "_to"];
if (from || to) {
const cell = row[field.name];
if (cell == null) return false;
if (field.type === "integer" || field.type === "number") {
if (from && Number(cell) < Number(from)) return false;
if (to && Number(cell) > Number(to)) return false;
} else {
if (from && String(cell) < String(from)) return false;
if (to && String(cell) > String(to)) return false;
}
}
continue;
}
const val = filters[field.name];
if (!val) continue;
const cell = row[field.name];
if (cell == null) return false;
let str: string;
if (field.fk && typeof cell === "object" && cell !== null) {
const targetRes = allResources.find((r) => r.name === field.fk!.resource);
if (targetRes) {
const items = Array.isArray(cell) ? cell : [cell];
str = items.map((item: any) => applyDisplayFormat(item, targetRes.displayFormat)).join(" ");
} else {
str = String(cell);
}
} else {
str = String(cell);
}
const filterParts = val.split(",").filter(Boolean);
if (!filterParts.some((part) => str.toLowerCase().includes(part.toLowerCase()))) return false;
}
return true;
}
export function ResourceList({ resource, basePath }: ResourceListProps) {
const navigate = useNavigate();
const { components, ...crud } = useResource(resource.name);
const { resources: allResources, config } = useAppContext();
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(resource.pagination?.defaultLimit ?? 20);
const [sortField, setSortField] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [filters, setFilters] = useState<Record<string, string>>({});
const [detailRow, setDetailRow] = useState<any | null>(null);
const isStreaming = resource.streaming === true;
const hasActions = resource.operations.get || resource.operations.update || resource.operations.delete;
const filterMode = config.resourceConfig?.[resource.name]?.filterOptions?.mode ?? "client";
const isClientMode = filterMode === "client" && !isStreaming;
const visibleColumns = resource.listColumns
.map((colName) => resource.fields.find((f) => f.name === colName))
.filter((f): f is FieldConfig => !!f && !f.hidden?.list);
useEffect(() => {
setFilters({});
}, [resource.name]);
useEffect(() => {
if (!isStreaming || !crud.stream) return;
setData(readSseCache(resource.name));
setSseConnected(resource.name, false);
const sub = crud.stream({
onEvent: (evt) => {
const enriched = { ...evt, _received_at: new Date().toISOString(), _seq: nextSseSeq() };
const updated = appendSseCache(resource.name, enriched);
setData(updated);
},
onOpen: () => setSseConnected(resource.name, true),
onError: () => setSseConnected(resource.name, false),
});
return () => {
setSseConnected(resource.name, false);
sub.close();
};
}, [isStreaming, crud.stream, resource.name]);
const serverFetchData = useCallback(async () => {
const params: Record<string, any> = {};
if (resource.pagination) {
params[resource.pagination.limitParam] = rowsPerPage;
params[resource.pagination.offsetParam] = page * rowsPerPage;
}
if (sortField) {
params.sort = sortDir === "desc" ? `-${sortField}` : sortField;
}
for (const [key, val] of Object.entries(filters)) {
if (val) params[key] = val;
}
const result = await crud.list(params);
setData(result.items ?? []);
setTotal(result.total ?? result.items?.length ?? 0);
}, [crud.list, resource.pagination, rowsPerPage, page, sortField, sortDir, filters]);
const clientFetchAll = useCallback(async () => {
const params: Record<string, any> = {};
if (resource.pagination) {
params[resource.pagination.limitParam] = 0;
}
const result = await crud.list(params);
setData(result.items ?? []);
setTotal(result.items?.length ?? 0);
}, [crud.list, resource.pagination]);
useEffect(() => {
if (isStreaming) return;
if (isClientMode) {
clientFetchAll();
} else {
serverFetchData();
}
}, [isStreaming, isClientMode, clientFetchAll, serverFetchData]);
useEffect(() => {
if (isClientMode) {
setPage(0);
}
}, [filters, isClientMode]);
const filteredData = useMemo(() => {
if (!isClientMode) return data;
let items = data.filter((row) => matchRow(row, filters, resource.fields, allResources));
if (sortField) {
items = [...items].sort((a, b) => {
const aVal = a[sortField];
const bVal = b[sortField];
if (aVal == null) return 1;
if (bVal == null) return -1;
if (aVal < bVal) return sortDir === "asc" ? -1 : 1;
if (aVal > bVal) return sortDir === "asc" ? 1 : -1;
return 0;
});
}
const start = page * rowsPerPage;
return items.slice(start, start + rowsPerPage);
}, [data, isClientMode, filters, sortField, sortDir, page, rowsPerPage, resource.fields, allResources]);
const clientTotal = useMemo(() => {
if (!isClientMode) return total;
return data.filter((row) => matchRow(row, filters, resource.fields, allResources)).length;
}, [data, isClientMode, filters, resource.fields, allResources]);
const displayData = isClientMode ? filteredData : data;
const displayTotal = isClientMode ? clientTotal : total;
const handleDelete = async (id: string | number) => {
if (!window.confirm("Are you sure you want to delete this item?")) return;
await crud.remove(id);
if (isClientMode) {
clientFetchAll();
} else {
serverFetchData();
}
};
const handleSort = (field: string) => {
if (sortField === field) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortDir("asc");
}
};
const handleFilterChange = (fieldName: string, value: string) => {
setFilters((prev) => ({ ...prev, [fieldName]: value }));
};
return (
<Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Typography variant="h5" fontWeight={700}>
{resource.displayName}
</Typography>
{isStreaming && <SseConnectionStatus resourceName={resource.name} />}
</Box>
<Box sx={{ display: "flex", gap: 1 }}>
{isStreaming && data.length > 0 && (
<Button variant="outlined" size="small" onClick={() => { setData([]); setTotal(0); clearSseCache(resource.name); }}>
Clear ({data.length})
</Button>
)}
{resource.operations.create && !isStreaming && (
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => navigate(`${basePath}/${resource.name}/new`)}
>
Create
</Button>
)}
</Box>
</Box>
{!isStreaming && (
<FilterBar
resourceName={resource.name}
filters={filters}
onFilterChange={handleFilterChange}
onClear={() => setFilters({})}
data={data}
/>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
{visibleColumns.map((col) => (
<TableCell key={col.name} sx={{ fontWeight: 700 }}>
{col.sortable ? (
<TableSortLabel
active={sortField === col.name}
direction={sortField === col.name ? sortDir : "asc"}
onClick={() => handleSort(col.name)}
>
{col.label}
</TableSortLabel>
) : (
col.label
)}
</TableCell>
))}
{hasActions && <TableCell align="right" sx={{ fontWeight: 700 }}>Actions</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{displayData.length === 0 ? (
<TableRow>
<TableCell colSpan={visibleColumns.length + (hasActions ? 1 : 0)} align="center">
<Typography variant="body2" color="text.secondary" sx={{ py: 4 }}>
{isStreaming ? "Waiting for events\u2026" : "No records found"}
</Typography>
</TableCell>
</TableRow>
) : (
displayData.map((row, idx) => {
const rowId = isStreaming ? `evt-${row._seq ?? idx}` : row[resource.primaryKey];
return (
<TableRow
key={rowId}
hover
sx={{ cursor: "pointer" }}
onClick={() => {
if (isStreaming) {
setDetailRow(row);
} else {
navigate(`${basePath}/${resource.name}/${rowId}`);
}
}}
>
{visibleColumns.map((col) => {
let value = row[col.name];
let fmt = resource.displayFormat;
if (col.fk) {
const targetRes = allResources.find((r) => r.name === col.fk!.resource);
fmt = targetRes!.displayFormat;
} else if (col.refSchema && !col.fk && col.inlineDisplayFormat) {
fmt = col.inlineDisplayFormat;
}
return (
<TableCell key={col.name}>
<ListCellRenderer field={col} value={value} displayFormat={fmt} />
</TableCell>
);
})}
{hasActions && (
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
{resource.operations.get && !isStreaming && (
<Tooltip title="View">
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}>
<VisibilityIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{resource.operations.update && (
<Tooltip title="Edit">
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}/edit`)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{resource.operations.delete && (
<Tooltip title="Delete">
<IconButton size="small" onClick={() => handleDelete(rowId)} color="error">
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</TableCell>
)}
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
{!isStreaming && (resource.pagination || isClientMode) && (
<TablePagination
component="div"
count={displayTotal}
page={page}
onPageChange={(_, p) => setPage(p)}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={(e) => {
setRowsPerPage(parseInt(e.target.value, 10));
setPage(0);
}}
rowsPerPageOptions={[10, 20, 50, 100]}
/>
)}
{!isStreaming && displayData.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: "block", textAlign: "right" }}>
{displayTotal} record{displayTotal !== 1 ? "s" : ""}
</Typography>
)}
<Dialog open={!!detailRow} onClose={() => setDetailRow(null)} maxWidth="sm" fullWidth>
<DialogTitle>{resource.displayName} Event</DialogTitle>
<DialogContent dividers>
{detailRow && (
<Grid container spacing={2} sx={{ mt: 0.5 }}>
{visibleColumns.map((col) => (
<Grid key={col.name} size={{ xs: 12, sm: 6 }}>
<DetailFieldRenderer
field={col}
value={detailRow[col.name]}
displayFormat={resource.displayFormat}
/>
</Grid>
))}
</Grid>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setDetailRow(null)}>Close</Button>
</DialogActions>
</Dialog>
</Box>
);
}