updated sse supporting react-openapi

This commit is contained in:
2026-06-18 20:32:34 +05:30
parent 0a668cf98d
commit 154b15fe51
28 changed files with 2132 additions and 440 deletions

View File

@@ -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>

View 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>
);
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>

View 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>
);
}

View 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&hellip;
</Typography>
)}
<Snackbar
open={snackbarOpen}
autoHideDuration={2000}
onClose={() => setSnackbarOpen(false)}
message={snackbarMsg}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
/>
</Paper>
);
}

View File

@@ -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

View File

@@ -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 };
}

View 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 };
}

View File

@@ -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");

View File

@@ -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;
}
}

View File

@@ -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 {