updated sse supporting react-openapi
This commit is contained in:
@@ -44,9 +44,13 @@ export function Admin({ basePath }: AdminProps) {
|
||||
{resources.map((r) => (
|
||||
<React.Fragment key={r.name}>
|
||||
<Route path={r.name} element={<ResourceList resource={r} basePath={basePath} />} />
|
||||
<Route path={`${r.name}/new`} element={<ResourceForm resource={r} basePath={basePath} mode="create" />} />
|
||||
<Route path={`${r.name}/:id`} element={<ResourceDetail resource={r} basePath={basePath} />} />
|
||||
<Route path={`${r.name}/:id/edit`} element={<ResourceForm resource={r} basePath={basePath} mode="edit" />} />
|
||||
{!r.streaming && (
|
||||
<>
|
||||
<Route path={`${r.name}/new`} element={<ResourceForm resource={r} basePath={basePath} mode="create" />} />
|
||||
<Route path={`${r.name}/:id`} element={<ResourceDetail resource={r} basePath={basePath} />} />
|
||||
<Route path={`${r.name}/:id/edit`} element={<ResourceForm resource={r} basePath={basePath} mode="edit" />} />
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Routes>
|
||||
|
||||
68
react-openapi/src/components/FilterBar.tsx
Normal file
68
react-openapi/src/components/FilterBar.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
import { Box, Button } from "@mui/material";
|
||||
import { useResource, FilterComponentProps } from "../context/useResource";
|
||||
|
||||
interface FilterBarProps {
|
||||
resourceName: string;
|
||||
filters: Record<string, string>;
|
||||
onFilterChange: (fieldName: string, value: string) => void;
|
||||
onClear: () => void;
|
||||
data?: any[];
|
||||
}
|
||||
|
||||
export function FilterBar({ resourceName, filters, onFilterChange, onClear, data }: FilterBarProps) {
|
||||
const { resource, components } = useResource(resourceName);
|
||||
const filterable = resource.fields.filter((f) => f.filterable);
|
||||
const hasActiveFilters = Object.values(filters).some((v) => v !== "");
|
||||
|
||||
if (filterable.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mb: 2, alignItems: "flex-start" }}>
|
||||
{filterable.map((field) => {
|
||||
const Component = components[field.name] as React.FC<FilterComponentProps>;
|
||||
const isRange = field.type === "integer" || field.type === "number" || field.format === "date" || field.format === "date-time";
|
||||
|
||||
if (isRange) {
|
||||
return (
|
||||
<Box key={field.name} sx={{ minWidth: 260, display: "flex", gap: 1 }}>
|
||||
<Box sx={{ flex: 1, minWidth: 120 }}>
|
||||
<Component
|
||||
labelOverride={`${field.label} From`}
|
||||
value={filters[field.name + "_from"] ?? ""}
|
||||
onChange={(v) => onFilterChange(field.name + "_from", v)}
|
||||
data={data}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 120 }}>
|
||||
<Component
|
||||
labelOverride={`${field.label} To`}
|
||||
value={filters[field.name + "_to"] ?? ""}
|
||||
onChange={(v) => onFilterChange(field.name + "_to", v)}
|
||||
data={data}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={field.name} sx={{ minWidth: 180 }}>
|
||||
<Component
|
||||
value={filters[field.name] ?? ""}
|
||||
onChange={(v) => onFilterChange(field.name, v)}
|
||||
data={data}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{hasActiveFilters && (
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Button size="small" variant="outlined" onClick={onClear}>
|
||||
Clear
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ interface ResourceDetailProps {
|
||||
export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const crud = useResource(resource);
|
||||
const crud = useResource(resource.name);
|
||||
const { resources: allResources } = useAppContext();
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -28,7 +28,7 @@ interface ResourceFormProps {
|
||||
export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const crud = useResource(resource);
|
||||
const crud = useResource(resource.name);
|
||||
const { resources: allResources } = useAppContext();
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
@@ -218,7 +218,7 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const title = mode === "create" ? `Create ${resource.schemaName}` : `Edit ${resource.schemaName}`;
|
||||
const title = mode === "create" ? `Create ${resource.displayName}` : `Edit ${resource.displayName}`;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP
|
||||
<CircleIcon sx={{ color: colors[i % colors.length], fontSize: 12 }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={r.schemaName}
|
||||
primary={r.displayName}
|
||||
primaryTypographyProps={{ fontWeight: active ? 700 : 500, fontSize: 14 }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
|
||||
34
react-openapi/src/components/SseConnectionStatus.tsx
Normal file
34
react-openapi/src/components/SseConnectionStatus.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
|
||||
import { useSseConnected } from "../context/useResource";
|
||||
|
||||
interface SseConnectionStatusProps {
|
||||
resourceName: string;
|
||||
}
|
||||
|
||||
export function SseConnectionStatus({ resourceName }: SseConnectionStatusProps) {
|
||||
const connected = useSseConnected(resourceName);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 1,
|
||||
border: 1,
|
||||
borderColor: connected ? "#4caf50" : "#f44336",
|
||||
color: connected ? "#4caf50" : "#f44336",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<FiberManualRecordIcon sx={{ fontSize: 10 }} />
|
||||
{connected ? "Connected" : "Disconnected"}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
96
react-openapi/src/components/SseStreamView.tsx
Normal file
96
react-openapi/src/components/SseStreamView.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Box, Typography, Paper, Chip, Snackbar,
|
||||
} from "@mui/material";
|
||||
import type { ResourceConfig } from "../types";
|
||||
import { useResource, readSseCache, appendSseCache, clearSseCache, nextSseSeq, setSseConnected } from "../context/useResource";
|
||||
import { applyDisplayFormat } from "./fields";
|
||||
import { SseConnectionStatus } from "./SseConnectionStatus";
|
||||
|
||||
interface SseStreamViewProps {
|
||||
resource: ResourceConfig;
|
||||
}
|
||||
|
||||
export function SseStreamView({ resource }: SseStreamViewProps) {
|
||||
const { stream } = useResource(resource.name);
|
||||
const [events, setEvents] = useState<any[]>(() => readSseCache(resource.name));
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||
const [snackbarMsg, setSnackbarMsg] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream) return;
|
||||
setSseConnected(resource.name, false);
|
||||
|
||||
const sub = stream({
|
||||
onEvent: (evt) => {
|
||||
const enriched = { ...evt, _received_at: new Date().toISOString(), _seq: nextSseSeq() };
|
||||
const updated = appendSseCache(resource.name, enriched);
|
||||
setEvents([...updated]);
|
||||
setSnackbarMsg(applyDisplayFormat(evt, resource.displayFormat));
|
||||
setSnackbarOpen(true);
|
||||
},
|
||||
onOpen: () => setSseConnected(resource.name, true),
|
||||
onError: () => setSseConnected(resource.name, false),
|
||||
});
|
||||
|
||||
return () => {
|
||||
setSseConnected(resource.name, false);
|
||||
sub.close();
|
||||
};
|
||||
}, [resource.name]);
|
||||
|
||||
const eventCount = events.length;
|
||||
const latestEvent = events[events.length - 1] ?? null;
|
||||
|
||||
return (
|
||||
<Paper variant="outlined" sx={{ p: 3, borderRadius: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2.5 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
{resource.displayName}
|
||||
</Typography>
|
||||
<SseConnectionStatus resourceName={resource.name} />
|
||||
</Box>
|
||||
<Chip
|
||||
label={eventCount > 0 ? `${eventCount} event${eventCount !== 1 ? "s" : ""}` : "No events"}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={eventCount > 0 ? "primary" : "default"}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{latestEvent ? (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: "grey.50",
|
||||
borderRadius: 1,
|
||||
p: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: "block" }}>
|
||||
Latest event (#{latestEvent._seq})
|
||||
</Typography>
|
||||
<Typography>
|
||||
{applyDisplayFormat(latestEvent, resource.displayFormat)}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: "center" }}>
|
||||
Waiting for events…
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
open={snackbarOpen}
|
||||
autoHideDuration={2000}
|
||||
onClose={() => setSnackbarOpen(false)}
|
||||
message={snackbarMsg}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,44 @@
|
||||
import React from "react";
|
||||
import { FormControl, FormControlLabel, Switch, FormHelperText } from "@mui/material";
|
||||
import { Box, FormControl, FormControlLabel, Switch, FormHelperText, ToggleButton, ToggleButtonGroup } from "@mui/material";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import CancelIcon from "@mui/icons-material/Cancel";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
nullable?: boolean;
|
||||
}
|
||||
|
||||
export function BooleanField({ field, value, onChange }: Props) {
|
||||
export function BooleanField({ field, value, onChange, nullable }: Props) {
|
||||
if (nullable) {
|
||||
const strValue = String(value ?? "");
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ fontSize: "0.75rem", color: "text.secondary", mb: 0.5, fontWeight: 600 }}>
|
||||
{field.label}
|
||||
</Box>
|
||||
<ToggleButtonGroup
|
||||
value={strValue}
|
||||
exclusive
|
||||
onChange={(_, v) => onChange(v ?? "")}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="" sx={{ color: "text.disabled", borderColor: "divider" }}>
|
||||
<Box sx={{ width: 16, height: 16, borderRadius: "50%", bgcolor: "action.disabledBackground" }} />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="true" sx={{ color: "success.main", borderColor: "success.main" }}>
|
||||
<CheckCircleIcon fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="false" sx={{ color: "error.main", borderColor: "error.main" }}>
|
||||
<CancelIcon fontSize="small" />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl component="fieldset" fullWidth size="small">
|
||||
<FormControlLabel
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { ResourceConfig, ParsedListResponse } from "../types";
|
||||
import { getApi } from "../hooks/useApi";
|
||||
|
||||
function parseError(e: any): string {
|
||||
if (e.response?.data) {
|
||||
const data = e.response.data;
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((err: any) => err.msg ?? String(err)).join("; ");
|
||||
}
|
||||
if (typeof data.detail === "string") {
|
||||
return data.detail;
|
||||
}
|
||||
}
|
||||
return e.message ?? "An error occurred";
|
||||
}
|
||||
|
||||
interface ResourceState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UseResourceReturn {
|
||||
list: (params?: Record<string, any>) => Promise<ParsedListResponse>;
|
||||
get: (id: string | number) => Promise<any>;
|
||||
create: (data: any) => Promise<any>;
|
||||
update: (id: string | number, data: any) => Promise<any>;
|
||||
remove: (id: string | number) => Promise<void>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useResource(resource: ResourceConfig): UseResourceReturn {
|
||||
const [state, setState] = useState<ResourceState>({ loading: false, error: null });
|
||||
|
||||
const setLoading = useCallback((loading: boolean) => {
|
||||
setState((s) => ({ ...s, loading }));
|
||||
}, []);
|
||||
|
||||
const setError = useCallback((error: string | null) => {
|
||||
setState((s) => ({ ...s, error }));
|
||||
}, []);
|
||||
|
||||
const list = useCallback(
|
||||
async (params?: Record<string, any>): Promise<ParsedListResponse> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
const res = await api.get(resource.path, { params });
|
||||
const data = res.data;
|
||||
|
||||
if (resource.pagination) {
|
||||
if (!data || typeof data !== "object" || !Array.isArray(data.items)) {
|
||||
throw new Error(`Expected paginated response { total, items } from ${resource.path}`);
|
||||
}
|
||||
return { items: data.items, total: data.total ?? data.items.length };
|
||||
}
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`Expected array response from ${resource.path}`);
|
||||
}
|
||||
return { items: data };
|
||||
} catch (e: any) {
|
||||
const msg = parseError(e);
|
||||
setError(msg);
|
||||
return { items: [] };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resource.path, resource.pagination, setLoading, setError]
|
||||
);
|
||||
|
||||
const get = useCallback(
|
||||
async (id: string | number): Promise<any> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
const res = await api.get(`${resource.path}/${id}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
setError(parseError(e));
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resource.path, setLoading, setError]
|
||||
);
|
||||
|
||||
const create = useCallback(
|
||||
async (data: any): Promise<any> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
const res = await api.post(resource.path, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
setError(parseError(e));
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resource.path, setLoading, setError]
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (id: string | number, data: any): Promise<any> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
const res = await api.put(`${resource.path}/${id}`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
setError(parseError(e));
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resource.path, setLoading, setError]
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string | number): Promise<void> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
await api.delete(`${resource.path}/${id}`);
|
||||
} catch (e: any) {
|
||||
setError(parseError(e));
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resource.path, setLoading, setError]
|
||||
);
|
||||
|
||||
return { list, get, create, update, remove, loading: state.loading, error: state.error };
|
||||
}
|
||||
509
react-openapi/src/context/useResource.tsx
Normal file
509
react-openapi/src/context/useResource.tsx
Normal file
@@ -0,0 +1,509 @@
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import { Autocomplete, TextField } from "@mui/material";
|
||||
import type { ResourceConfig, ParsedListResponse, FieldConfig } from "../types";
|
||||
import { useAppContext } from "./AppContext";
|
||||
import { getApi } from "../hooks/useApi";
|
||||
import { StringField } from "../components/fields/renderers/StringField";
|
||||
import { NumberField } from "../components/fields/renderers/NumberField";
|
||||
import { DateField } from "../components/fields/renderers/DateField";
|
||||
import { BooleanField } from "../components/fields/renderers/BooleanField";
|
||||
import { EnumField } from "../components/fields/renderers/EnumField";
|
||||
import { FkSelectField } from "../components/fields/renderers/FkSelectField";
|
||||
import { FkMultiSelectField } from "../components/fields/renderers/FkMultiSelectField";
|
||||
|
||||
function parseError(e: any): string {
|
||||
if (e.response?.data) {
|
||||
const data = e.response.data;
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((err: any) => err.msg ?? String(err)).join("; ");
|
||||
}
|
||||
if (typeof data.detail === "string") {
|
||||
return data.detail;
|
||||
}
|
||||
}
|
||||
return e.message ?? "An error occurred";
|
||||
}
|
||||
|
||||
interface ResourceState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface FilterComponentProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
data?: any[];
|
||||
labelOverride?: string;
|
||||
}
|
||||
|
||||
interface StreamHandlers {
|
||||
onEvent: (data: any) => void;
|
||||
onError?: (evt: Event) => void;
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
interface StreamSubscription {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
interface UseResourceReturn {
|
||||
resource: ResourceConfig;
|
||||
components: Record<string, React.FC<FilterComponentProps>>;
|
||||
list: (params?: Record<string, any>) => Promise<ParsedListResponse>;
|
||||
get: (id: string | number) => Promise<any>;
|
||||
create: (data: any) => Promise<any>;
|
||||
update: (id: string | number, data: any) => Promise<any>;
|
||||
remove: (id: string | number) => Promise<void>;
|
||||
stream?: (handlers: StreamHandlers) => StreamSubscription;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const _fkOptionsCache = new Map<string, { value: any; label: string }[]>();
|
||||
const _stringOptionsCache = new Map<string, string[]>();
|
||||
const _sseEventCache = new Map<string, any[]>();
|
||||
let _sseSeq = 0;
|
||||
|
||||
export function readSseCache(resourceName: string): any[] {
|
||||
return _sseEventCache.get(resourceName) ?? [];
|
||||
}
|
||||
|
||||
export function appendSseCache(resourceName: string, event: any): any[] {
|
||||
const events = _sseEventCache.get(resourceName) ?? [];
|
||||
events.push(event);
|
||||
if (events.length > 100) events.splice(0, events.length - 100);
|
||||
_sseEventCache.set(resourceName, events);
|
||||
return events;
|
||||
}
|
||||
|
||||
export function clearSseCache(resourceName: string): void {
|
||||
_sseEventCache.delete(resourceName);
|
||||
}
|
||||
|
||||
export function nextSseSeq(): number {
|
||||
return ++_sseSeq;
|
||||
}
|
||||
|
||||
const _sseConnection = new Map<string, boolean>();
|
||||
const _sseListeners = new Map<string, Set<() => void>>();
|
||||
|
||||
export function setSseConnected(resourceName: string, connected: boolean): void {
|
||||
if (_sseConnection.get(resourceName) === connected) return;
|
||||
_sseConnection.set(resourceName, connected);
|
||||
_sseListeners.get(resourceName)?.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
export function getSseConnected(resourceName: string): boolean {
|
||||
return _sseConnection.get(resourceName) ?? false;
|
||||
}
|
||||
|
||||
export function useSseConnected(resourceName: string): boolean {
|
||||
const [connected, setConnected] = useState(() => getSseConnected(resourceName));
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => setConnected(getSseConnected(resourceName));
|
||||
const listeners = _sseListeners.get(resourceName) ?? new Set();
|
||||
listeners.add(cb);
|
||||
_sseListeners.set(resourceName, listeners);
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
if (listeners.size === 0) _sseListeners.delete(resourceName);
|
||||
};
|
||||
}, [resourceName]);
|
||||
|
||||
return connected;
|
||||
}
|
||||
|
||||
function extractDataOptions(data: any[], fieldName: string): string[] {
|
||||
const values = new Set<string>();
|
||||
for (const row of data) {
|
||||
const v = row[fieldName];
|
||||
if (v != null && v !== "") {
|
||||
values.add(String(v));
|
||||
}
|
||||
}
|
||||
return [...values].sort();
|
||||
}
|
||||
|
||||
function applyDisplayFormat(obj: any, format: string): string {
|
||||
if (!obj || typeof obj !== "object") return String(obj ?? "");
|
||||
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
|
||||
}
|
||||
|
||||
function buildFilterComponent(field: FieldConfig, resourceName: string): React.FC<FilterComponentProps> {
|
||||
if (field.type === "boolean") {
|
||||
return ({ value, onChange, labelOverride }) => (
|
||||
<BooleanField
|
||||
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? "")}
|
||||
nullable
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.fk) {
|
||||
const FkFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
|
||||
const { resources, config } = useAppContext();
|
||||
const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server";
|
||||
const targetRes = resources.find((r) => r.name === field.fk!.resource);
|
||||
const [options, setOptions] = useState<{ value: any; label: string }[]>([]);
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (filterMode === "client" && data && targetRes) {
|
||||
const seen = new Set<string>();
|
||||
const opts: { value: any; label: string }[] = [];
|
||||
for (const row of data) {
|
||||
const items = Array.isArray(row[field.name]) ? row[field.name] : [row[field.name]];
|
||||
for (const item of items) {
|
||||
if (item == null) continue;
|
||||
const label = applyDisplayFormat(item, targetRes.displayFormat);
|
||||
if (!seen.has(label)) {
|
||||
seen.add(label);
|
||||
opts.push({ value: label, label });
|
||||
}
|
||||
}
|
||||
}
|
||||
opts.sort((a, b) => a.label.localeCompare(b.label));
|
||||
setOptions(opts);
|
||||
fetched.current = true;
|
||||
} else if (filterMode === "server" && targetRes && !fetched.current) {
|
||||
const cacheKey = targetRes.name;
|
||||
if (_fkOptionsCache.has(cacheKey)) {
|
||||
setOptions(_fkOptionsCache.get(cacheKey)!);
|
||||
fetched.current = true;
|
||||
} else {
|
||||
(async () => {
|
||||
try {
|
||||
const api = getApi();
|
||||
const params: Record<string, any> = {};
|
||||
if (targetRes.pagination) params.limit = 0;
|
||||
const res = await api.get(targetRes.path, { params });
|
||||
let items: any[];
|
||||
if (targetRes.pagination) {
|
||||
items = res.data.items ?? [];
|
||||
} else {
|
||||
items = Array.isArray(res.data) ? res.data : [];
|
||||
}
|
||||
const opts = items.map((item: any) => {
|
||||
const label = applyDisplayFormat(item, targetRes.displayFormat);
|
||||
return { value: label, label };
|
||||
});
|
||||
_fkOptionsCache.set(cacheKey, opts);
|
||||
setOptions(opts);
|
||||
fetched.current = true;
|
||||
} catch {
|
||||
fetched.current = true;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
}, [filterMode, data, targetRes]);
|
||||
|
||||
if (field.isArray) {
|
||||
const selected = value ? value.split(",").filter(Boolean) : [];
|
||||
return (
|
||||
<FkMultiSelectField
|
||||
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
||||
value={selected}
|
||||
onChange={(v: any[]) => onChange(v.join(","))}
|
||||
fkOptions={options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FkSelectField
|
||||
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
||||
value={value}
|
||||
onChange={(v: any) => onChange(v ?? "")}
|
||||
fkOptions={options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return FkFilter;
|
||||
}
|
||||
|
||||
if (field.enumValues) {
|
||||
const EnumFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
|
||||
const dataOptions = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return extractDataOptions(data, field.name);
|
||||
}, [data]);
|
||||
const merged = useMemo(
|
||||
() => [...new Set([...(field.enumValues ?? []), ...dataOptions])],
|
||||
[dataOptions]
|
||||
);
|
||||
return (
|
||||
<EnumField
|
||||
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label, enumValues: merged }}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? "")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return EnumFilter;
|
||||
}
|
||||
|
||||
if (field.type === "integer" || field.type === "number") {
|
||||
return ({ value, onChange, labelOverride }) => (
|
||||
<NumberField
|
||||
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v === "" ? "" : String(v))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.format === "date" || field.format === "date-time") {
|
||||
return ({ value, onChange, labelOverride }) => (
|
||||
<DateField
|
||||
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? "")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!field.fk &&
|
||||
!field.enumValues &&
|
||||
field.type !== "boolean" &&
|
||||
field.type !== "integer" &&
|
||||
field.type !== "number" &&
|
||||
field.format !== "date" &&
|
||||
field.format !== "date-time" &&
|
||||
!field.refSchema
|
||||
) {
|
||||
const StringAutocompleteFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
|
||||
const { resources, config } = useAppContext();
|
||||
const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server";
|
||||
const [options, setOptions] = useState<string[]>([]);
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (filterMode === "client" && data) {
|
||||
setOptions(extractDataOptions(data, field.name));
|
||||
fetched.current = true;
|
||||
} else if (filterMode === "server" && !fetched.current) {
|
||||
const cacheKey = resourceName + ":" + field.name;
|
||||
if (_stringOptionsCache.has(cacheKey)) {
|
||||
setOptions(_stringOptionsCache.get(cacheKey)!);
|
||||
fetched.current = true;
|
||||
} else {
|
||||
(async () => {
|
||||
try {
|
||||
const api = getApi();
|
||||
const selfRes = resources.find((r) => r.name === resourceName);
|
||||
if (!selfRes) { fetched.current = true; return; }
|
||||
const params: Record<string, any> = {};
|
||||
if (selfRes.pagination) params.limit = 0;
|
||||
const res = await api.get(selfRes.path, { params });
|
||||
let items: any[];
|
||||
if (selfRes.pagination) {
|
||||
items = res.data.items ?? [];
|
||||
} else {
|
||||
items = Array.isArray(res.data) ? res.data : [];
|
||||
}
|
||||
const values = [...new Set(items.map((r: any) => String(r[field.name] ?? "")).filter(Boolean))].sort();
|
||||
_stringOptionsCache.set(cacheKey, values);
|
||||
setOptions(values);
|
||||
fetched.current = true;
|
||||
} catch {
|
||||
fetched.current = true;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
size="small"
|
||||
options={options}
|
||||
value={value || null}
|
||||
onInputChange={(_, newVal) => onChange(newVal ?? "")}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={labelOverride ?? field.label}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return StringAutocompleteFilter;
|
||||
}
|
||||
|
||||
return ({ value, onChange, labelOverride }) => (
|
||||
<StringField
|
||||
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? "")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function useResource(resourceName: string): UseResourceReturn {
|
||||
const { resources } = useAppContext();
|
||||
const resource = resources.find((r) => r.name === resourceName);
|
||||
if (!resource) {
|
||||
throw new Error(`Resource "${resourceName}" not found`);
|
||||
}
|
||||
|
||||
const [state, setState] = useState<ResourceState>({ loading: false, error: null });
|
||||
|
||||
const setLoading = useCallback((loading: boolean) => {
|
||||
setState((s) => ({ ...s, loading }));
|
||||
}, []);
|
||||
|
||||
const setError = useCallback((error: string | null) => {
|
||||
setState((s) => ({ ...s, error }));
|
||||
}, []);
|
||||
|
||||
const list = useCallback(
|
||||
async (params?: Record<string, any>): Promise<ParsedListResponse> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
const res = await api.get(resource.path, { params });
|
||||
const data = res.data;
|
||||
|
||||
if (resource.pagination) {
|
||||
if (!data || typeof data !== "object" || !Array.isArray(data.items)) {
|
||||
throw new Error(`Expected paginated response { total, items } from ${resource.path}`);
|
||||
}
|
||||
return { items: data.items, total: data.total ?? data.items.length };
|
||||
}
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`Expected array response from ${resource.path}`);
|
||||
}
|
||||
return { items: data };
|
||||
} catch (e: any) {
|
||||
const msg = parseError(e);
|
||||
setError(msg);
|
||||
return { items: [] };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resource.path, resource.pagination, setLoading, setError]
|
||||
);
|
||||
|
||||
const get = useCallback(
|
||||
async (id: string | number): Promise<any> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
const res = await api.get(`${resource.path}/${id}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
setError(parseError(e));
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resource.path, setLoading, setError]
|
||||
);
|
||||
|
||||
const create = useCallback(
|
||||
async (data: any): Promise<any> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
const res = await api.post(resource.path, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
setError(parseError(e));
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resource.path, setLoading, setError]
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (id: string | number, data: any): Promise<any> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
const res = await api.put(`${resource.path}/${id}`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
setError(parseError(e));
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resource.path, setLoading, setError]
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string | number): Promise<void> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
await api.delete(`${resource.path}/${id}`);
|
||||
} catch (e: any) {
|
||||
setError(parseError(e));
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resource.path, setLoading, setError]
|
||||
);
|
||||
|
||||
const stream = useCallback(
|
||||
(handlers: StreamHandlers): StreamSubscription => {
|
||||
if (!resource.streaming) {
|
||||
throw new Error(`Resource "${resourceName}" does not support streaming`);
|
||||
}
|
||||
const api = getApi();
|
||||
const baseUrl = (api.defaults.baseURL ?? "").replace(/\/+$/, "");
|
||||
const url = baseUrl + resource.path;
|
||||
const es = new EventSource(url);
|
||||
|
||||
es.onopen = () => handlers.onOpen?.();
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
handlers.onEvent(data);
|
||||
} catch {
|
||||
// ignore malformed JSON payloads
|
||||
}
|
||||
};
|
||||
es.onerror = (e) => {
|
||||
handlers.onError?.(e);
|
||||
};
|
||||
|
||||
return { close: () => es.close() };
|
||||
},
|
||||
[resource.path, resource.streaming, resourceName]
|
||||
);
|
||||
|
||||
const components = useMemo(
|
||||
() => {
|
||||
const map: Record<string, React.FC<FilterComponentProps>> = {};
|
||||
for (const field of resource.fields) {
|
||||
map[field.name] = buildFilterComponent(field, resourceName);
|
||||
}
|
||||
return map;
|
||||
},
|
||||
[resource.fields, resourceName]
|
||||
);
|
||||
|
||||
return { resource, components, list, get, create, update, remove, stream: resource.streaming ? stream : undefined, loading: state.loading, error: state.error };
|
||||
}
|
||||
@@ -96,6 +96,9 @@ export function validateSpec(spec: OpenApiSpec): ValidationMessage[] {
|
||||
messages.push({ type: "error", message: `"${resourcePath}" has no GET list endpoint — datatable cannot be populated` });
|
||||
}
|
||||
|
||||
const isSSE = collectionPath?.get?.["x-sse"] === true;
|
||||
if (isSSE) continue;
|
||||
|
||||
const listParams = collectionPath?.get?.parameters ?? [];
|
||||
const limitParam = listParams.find((p: any) => p.in === "query" && p.name === "limit");
|
||||
const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenApiSpec, ResourceConfig, FieldConfig, ResourceRelationship } from "../types";
|
||||
import type { OpenApiSpec, ResourceConfig, FieldConfig } from "../types";
|
||||
import { extractFields } from "./field-config";
|
||||
import { extractRelationships } from "./relationship-config";
|
||||
|
||||
@@ -28,6 +28,25 @@ function sortFields(fields: FieldConfig[]): FieldConfig[] {
|
||||
});
|
||||
}
|
||||
|
||||
function formatDisplayName(name: string): string {
|
||||
return name.split(/[-_]/).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
|
||||
}
|
||||
|
||||
const SSE_RECEIVED_FIELD: FieldConfig = {
|
||||
name: "_received_at",
|
||||
label: "Received",
|
||||
description: "Timestamp when the event was received",
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
order: 0,
|
||||
hidden: {},
|
||||
filterable: false,
|
||||
sortable: true,
|
||||
readOnly: true,
|
||||
required: false,
|
||||
isArray: false,
|
||||
};
|
||||
|
||||
export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
|
||||
const schemas = spec.components?.schemas ?? {};
|
||||
const paths = spec.paths ?? {};
|
||||
@@ -46,10 +65,12 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
|
||||
|
||||
const fields = extractFields(schemaName, schema, schemas);
|
||||
const relationships = extractRelationships(schema, schemas);
|
||||
const hasSSE = collectionPathObj?.get?.["x-sse"] === true;
|
||||
|
||||
const resource: ResourceConfig = {
|
||||
name: resourceName,
|
||||
schemaName,
|
||||
displayName: formatDisplayName(resourceName),
|
||||
path: resourcePath,
|
||||
primaryKey: schema["x-primary-key"],
|
||||
displayFormat: schema["x-display-format"],
|
||||
@@ -65,10 +86,21 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
|
||||
},
|
||||
pagination: detectPagination(collectionPathObj),
|
||||
relationships,
|
||||
streaming: hasSSE || undefined,
|
||||
};
|
||||
|
||||
if (hasSSE) {
|
||||
resource.operations = { list: true, get: false, create: false, update: false, delete: false };
|
||||
resource.pagination = null;
|
||||
resource.relationships = [];
|
||||
resource.fields = [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))];
|
||||
resource.orderedFields = sortFields(resource.fields);
|
||||
resource.listColumns = ["_received_at", ...resource.listColumns];
|
||||
resource.primaryKey = "_received_at";
|
||||
}
|
||||
|
||||
configs.push(resource);
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
export type FilterMode = "client" | "server";
|
||||
|
||||
export interface ResourceConfiguration {
|
||||
filterOptions?: {
|
||||
mode?: FilterMode;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SpecConfiguration {
|
||||
specUrl: string;
|
||||
baseApiUrl?: string;
|
||||
title?: string;
|
||||
getToken?: () => string | null;
|
||||
resourceConfig?: Record<string, ResourceConfiguration>;
|
||||
}
|
||||
|
||||
export interface ValidationMessage {
|
||||
@@ -19,6 +28,7 @@ export interface ResourceRelationship {
|
||||
export interface ResourceConfig {
|
||||
name: string;
|
||||
schemaName: string;
|
||||
displayName: string;
|
||||
path: string;
|
||||
primaryKey: string;
|
||||
displayFormat: string;
|
||||
@@ -38,6 +48,7 @@ export interface ResourceConfig {
|
||||
defaultLimit: number;
|
||||
} | null;
|
||||
relationships: ResourceRelationship[];
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
export interface FieldConfig {
|
||||
|
||||
Reference in New Issue
Block a user