updated sse supporting react-openapi
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
@@ -14,42 +14,127 @@ import {
|
||||
TableRow,
|
||||
TablePagination,
|
||||
Paper,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
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 SearchIcon from "@mui/icons-material/Search";
|
||||
import type { ResourceConfig, FieldConfig } from "../types";
|
||||
import { useResource } from "../context/useResource";
|
||||
import { useAppContext } from "../context/AppContext";
|
||||
import { ListCellRenderer, applyDisplayFormat } from "./fields";
|
||||
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 crud = useResource(resource);
|
||||
const { resources: allResources } = useAppContext();
|
||||
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 [search, setSearch] = useState("");
|
||||
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);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
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;
|
||||
@@ -58,19 +143,76 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
|
||||
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]);
|
||||
}, [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(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
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);
|
||||
fetchData();
|
||||
if (isClientMode) {
|
||||
clientFetchAll();
|
||||
} else {
|
||||
serverFetchData();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
@@ -82,39 +224,46 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (fieldName: string, value: string) => {
|
||||
setFilters((prev) => ({ ...prev, [fieldName]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700}>
|
||||
{resource.schemaName}
|
||||
</Typography>
|
||||
{resource.operations.create && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => navigate(`${basePath}/${resource.name}/new`)}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<Box sx={{ mb: 2, display: "flex", gap: 2, alignItems: "center" }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{ minWidth: 280 }}
|
||||
{!isStreaming && (
|
||||
<FilterBar
|
||||
resourceName={resource.name}
|
||||
filters={filters}
|
||||
onFilterChange={handleFilterChange}
|
||||
onClear={() => setFilters({})}
|
||||
data={data}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
@@ -135,27 +284,33 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell align="right" sx={{ fontWeight: 700 }}>Actions</TableCell>
|
||||
{hasActions && <TableCell align="right" sx={{ fontWeight: 700 }}>Actions</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.length === 0 ? (
|
||||
{displayData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={visibleColumns.length + 1} align="center">
|
||||
<TableCell colSpan={visibleColumns.length + (hasActions ? 1 : 0)} align="center">
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 4 }}>
|
||||
No records found
|
||||
{isStreaming ? "Waiting for events\u2026" : "No records found"}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((row) => {
|
||||
const rowId = row[resource.primaryKey];
|
||||
displayData.map((row, idx) => {
|
||||
const rowId = isStreaming ? `evt-${row._seq ?? idx}` : row[resource.primaryKey];
|
||||
return (
|
||||
<TableRow
|
||||
key={rowId}
|
||||
hover
|
||||
sx={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}
|
||||
onClick={() => {
|
||||
if (isStreaming) {
|
||||
setDetailRow(row);
|
||||
} else {
|
||||
navigate(`${basePath}/${resource.name}/${rowId}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{visibleColumns.map((col) => {
|
||||
let value = row[col.name];
|
||||
@@ -172,29 +327,31 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
||||
{resource.operations.get && (
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
})
|
||||
@@ -203,10 +360,10 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{resource.pagination && (
|
||||
{!isStreaming && (resource.pagination || isClientMode) && (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
count={displayTotal}
|
||||
page={page}
|
||||
onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rowsPerPage}
|
||||
@@ -217,6 +374,34 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
|
||||
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} item 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user