report-fetch-request-ui #7
@@ -49,8 +49,8 @@ export default function EnhancedTable({
|
||||
config,
|
||||
data,
|
||||
total,
|
||||
paginationModel,
|
||||
onPaginationModelChange,
|
||||
paginationModel: externalPaginationModel,
|
||||
onPaginationModelChange: externalOnPaginationModelChange,
|
||||
loading = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -60,6 +60,14 @@ export default function EnhancedTable({
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isServer = config.mode !== "client";
|
||||
const [internalPaginationModel, setInternalPaginationModel] = React.useState<GridPaginationModel>({
|
||||
page: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const paginationModel = isServer ? externalPaginationModel : internalPaginationModel;
|
||||
const onPaginationModelChange = isServer ? externalOnPaginationModelChange : setInternalPaginationModel;
|
||||
|
||||
const columns: GridColDef[] = React.useMemo(() => {
|
||||
const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => {
|
||||
@@ -161,20 +169,18 @@ export default function EnhancedTable({
|
||||
rows={data || []}
|
||||
columns={columns}
|
||||
autoHeight
|
||||
paginationMode={config.pagination ? 'server' : 'client'}
|
||||
rowCount={(() => {
|
||||
if (!config.pagination) return data.length;
|
||||
if (total !== undefined) return total;
|
||||
|
||||
// Graceful fallback for missing total count
|
||||
const page = paginationModel?.page || 0;
|
||||
const pageSize = paginationModel?.pageSize || 10;
|
||||
if (data.length < pageSize) {
|
||||
return page * pageSize + data.length;
|
||||
}
|
||||
// Enable 'Next' button by pretending there's at least one more page
|
||||
return (page + 2) * pageSize;
|
||||
})()}
|
||||
paginationMode={isServer ? 'server' : 'client'}
|
||||
{...(isServer ? {
|
||||
rowCount: (() => {
|
||||
if (total !== undefined) return total;
|
||||
const page = paginationModel?.page || 0;
|
||||
const pageSize = paginationModel?.pageSize || 10;
|
||||
if (data.length < pageSize) {
|
||||
return page * pageSize + data.length;
|
||||
}
|
||||
return (page + 2) * pageSize;
|
||||
})(),
|
||||
} : {})}
|
||||
loading={loading}
|
||||
paginationModel={paginationModel || { page: 0, pageSize: 10 }}
|
||||
onPaginationModelChange={onPaginationModelChange}
|
||||
|
||||
286
react-openapi/components/FilterBar.tsx
Normal file
286
react-openapi/components/FilterBar.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||
import { ResourceField, ResourceMode } from "../types/config";
|
||||
|
||||
function getDisplayValue(item: any, field: ResourceField): string {
|
||||
if (!item) return "";
|
||||
const df = field.displayField;
|
||||
if (!df) return item.name || item.title || item.label || String(item.id ?? "");
|
||||
if (Array.isArray(df)) {
|
||||
return df.map((k) => item[k]).filter((v) => v != null).join(" ");
|
||||
}
|
||||
return item[df] ?? String(item.id ?? "");
|
||||
}
|
||||
|
||||
function extractOptions(
|
||||
fieldName: string,
|
||||
field: ResourceField,
|
||||
data: any[]
|
||||
): string[] {
|
||||
const values = new Set<string>();
|
||||
|
||||
if (field.options) {
|
||||
return field.options;
|
||||
}
|
||||
|
||||
if (!data) return [];
|
||||
|
||||
for (const item of data) {
|
||||
const v = item[fieldName];
|
||||
if (v == null) continue;
|
||||
|
||||
if (field.type === "array" && Array.isArray(v)) {
|
||||
for (const el of v) {
|
||||
if (el != null && typeof el === "object") {
|
||||
const d = getDisplayValue(el, field);
|
||||
if (d) values.add(d);
|
||||
} else if (el != null) {
|
||||
values.add(String(el));
|
||||
}
|
||||
}
|
||||
} else if (typeof v === "object") {
|
||||
const d = getDisplayValue(v, field);
|
||||
if (d) values.add(d);
|
||||
} else {
|
||||
values.add(String(v));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(values).sort();
|
||||
}
|
||||
|
||||
function renderFilterInput(
|
||||
fieldName: string,
|
||||
field: ResourceField,
|
||||
options: string[],
|
||||
value: any,
|
||||
onChange: (key: string, val: any) => void
|
||||
) {
|
||||
const isRange =
|
||||
field.type === "number" || field.type === "datetime" || field.type === "date";
|
||||
|
||||
if (isRange) {
|
||||
const rangeVal = (value as { min?: string; max?: string; start?: string; end?: string }) || {};
|
||||
const isDate = field.type === "datetime" || field.type === "date";
|
||||
const inputType = isDate ? "datetime-local" : "number";
|
||||
|
||||
if (isDate) {
|
||||
return (
|
||||
<Box key={fieldName} sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||
<Typography variant="caption" sx={{ minWidth: 80, color: "text.secondary" }}>
|
||||
{field.label}
|
||||
</Typography>
|
||||
<TextField
|
||||
type={inputType}
|
||||
placeholder="From"
|
||||
size="small"
|
||||
value={rangeVal.start ?? ""}
|
||||
onChange={(e) => onChange("start", e.target.value || undefined)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={{ width: 190 }}
|
||||
/>
|
||||
<TextField
|
||||
type={inputType}
|
||||
placeholder="To"
|
||||
size="small"
|
||||
value={rangeVal.end ?? ""}
|
||||
onChange={(e) => onChange("end", e.target.value || undefined)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={{ width: 190 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={fieldName} sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||
<Typography variant="caption" sx={{ minWidth: 80, color: "text.secondary" }}>
|
||||
{field.label}
|
||||
</Typography>
|
||||
<TextField
|
||||
type={inputType}
|
||||
placeholder="Min"
|
||||
size="small"
|
||||
value={rangeVal.min ?? ""}
|
||||
onChange={(e) => onChange("min", e.target.value || undefined)}
|
||||
sx={{ width: 120 }}
|
||||
/>
|
||||
<TextField
|
||||
type={inputType}
|
||||
placeholder="Max"
|
||||
size="small"
|
||||
value={rangeVal.max ?? ""}
|
||||
onChange={(e) => onChange("max", e.target.value || undefined)}
|
||||
sx={{ width: 120 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return (
|
||||
<FormControl key={fieldName} size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel>{field.label}</InputLabel>
|
||||
<Select
|
||||
value={value ?? ""}
|
||||
label={field.label}
|
||||
onChange={(e) => onChange("value", e.target.value || undefined)}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
<MenuItem value="true">Yes</MenuItem>
|
||||
<MenuItem value="false">No</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
if (options.length <= 20) {
|
||||
return (
|
||||
<Autocomplete
|
||||
key={fieldName}
|
||||
options={options}
|
||||
value={value ?? null}
|
||||
onChange={(_, val) => onChange("value", val || undefined)}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label={field.label} size="small" />
|
||||
)}
|
||||
sx={{ minWidth: 180 }}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
key={fieldName}
|
||||
label={field.label}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange("value", e.target.value || undefined)}
|
||||
size="small"
|
||||
sx={{ minWidth: 180 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface FilterBarProps {
|
||||
fields: Record<string, ResourceField>;
|
||||
filterableFields: string[];
|
||||
mode: ResourceMode;
|
||||
data?: any[];
|
||||
appliedValues: Record<string, any>;
|
||||
onApply: (values: Record<string, any>) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export default function FilterBar({
|
||||
fields,
|
||||
filterableFields,
|
||||
data,
|
||||
appliedValues,
|
||||
onApply,
|
||||
onClear,
|
||||
}: FilterBarProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [draft, setDraft] = React.useState<Record<string, any>>(() => ({ ...appliedValues }));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) setDraft({ ...appliedValues });
|
||||
}, [appliedValues, open]);
|
||||
|
||||
if (!filterableFields || filterableFields.length === 0) return null;
|
||||
|
||||
const activeCount = Object.keys(appliedValues).filter((k) => {
|
||||
const v = appliedValues[k];
|
||||
if (v == null || v === "") return false;
|
||||
if (typeof v === "object" && Object.values(v).every((x) => x == null || x === "")) return false;
|
||||
return true;
|
||||
}).length;
|
||||
|
||||
const handleApply = () => onApply({ ...draft });
|
||||
const handleClear = () => {
|
||||
setDraft({});
|
||||
onClear();
|
||||
};
|
||||
|
||||
const updateDraft = (fieldName: string, key: string, val: any) => {
|
||||
setDraft((prev) => {
|
||||
if (key === "value") {
|
||||
return { ...prev, [fieldName]: val };
|
||||
}
|
||||
const existing = prev[fieldName] || {};
|
||||
return { ...prev, [fieldName]: { ...existing, [key]: val } };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper variant="outlined" sx={{ mb: 2, borderRadius: 2, overflow: "hidden" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
px: 2,
|
||||
py: 1,
|
||||
cursor: "pointer",
|
||||
"&:hover": { bgcolor: "action.hover" },
|
||||
}}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<FilterListIcon fontSize="small" color="action" />
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
{open ? "Hide Filters" : "Show Filters"}
|
||||
</Typography>
|
||||
</Box>
|
||||
{activeCount > 0 && (
|
||||
<Typography variant="caption" color="primary" fontWeight={600}>
|
||||
{activeCount} active
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{open && (
|
||||
<Box sx={{ px: 2, pb: 2, borderTop: "1px solid", borderColor: "divider", pt: 2 }}>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 2, alignItems: "flex-end" }}>
|
||||
{filterableFields.map((fieldName) => {
|
||||
const field = fields[fieldName];
|
||||
if (!field) return null;
|
||||
|
||||
const options = extractOptions(fieldName, field, data ?? []);
|
||||
const raw = draft[fieldName];
|
||||
|
||||
return (
|
||||
<React.Fragment key={fieldName}>
|
||||
{renderFilterInput(fieldName, field, options, raw, (key, val) =>
|
||||
updateDraft(fieldName, key, val)
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, display: "flex", gap: 1 }}>
|
||||
<Button variant="contained" size="small" onClick={handleApply}>
|
||||
Apply
|
||||
</Button>
|
||||
<Button variant="outlined" size="small" onClick={handleClear}>
|
||||
Clear
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Typography, Paper, CircularProgress } from '@mui/material';
|
||||
import { Box, Paper, CircularProgress } from '@mui/material';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
import type { ResourceField } from '../types/config';
|
||||
import { useResource } from '../hooks/useResource';
|
||||
import GenericForm from './GenericForm';
|
||||
import EnhancedTable from './EnhancedTable';
|
||||
import { useParams, useLocation, useNavigate, Routes, Route } from 'react-router-dom';
|
||||
import FilterBar from './FilterBar';
|
||||
import { useParams, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
interface ResourceViewProps {
|
||||
config: ResourceConfig;
|
||||
@@ -13,36 +15,111 @@ interface ResourceViewProps {
|
||||
|
||||
import { GridPaginationModel } from '@mui/x-data-grid';
|
||||
|
||||
function getFilterDisplayFields(field: ResourceField): string[] {
|
||||
if (!field.displayField) return ["name", "title", "label"];
|
||||
return (Array.isArray(field.displayField) ? field.displayField : [field.displayField]).filter(
|
||||
(df): df is string => !!df
|
||||
);
|
||||
}
|
||||
|
||||
function applyClientFilters(
|
||||
data: any[],
|
||||
filters: Record<string, any>,
|
||||
fields: Record<string, ResourceField>
|
||||
): any[] {
|
||||
const entries = Object.entries(filters).filter(([_, v]) => {
|
||||
if (v == null || v === "") return false;
|
||||
if (typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => x == null || x === "")) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (entries.length === 0) return data;
|
||||
|
||||
return data.filter((item) =>
|
||||
entries.every(([fieldName, filterValue]) => {
|
||||
const field = fields[fieldName];
|
||||
if (!field) return true;
|
||||
|
||||
const itemValue = item[fieldName];
|
||||
|
||||
if (typeof filterValue === "object" && !Array.isArray(filterValue)) {
|
||||
if (field.type === "number") {
|
||||
if (filterValue.min != null && filterValue.min !== "" && Number(itemValue) < Number(filterValue.min)) return false;
|
||||
if (filterValue.max != null && filterValue.max !== "" && Number(itemValue) > Number(filterValue.max)) return false;
|
||||
return true;
|
||||
}
|
||||
if (field.type === "datetime" || field.type === "date") {
|
||||
const itemTime = new Date(itemValue).getTime();
|
||||
if (filterValue.start && new Date(filterValue.start).getTime() > itemTime) return false;
|
||||
if (filterValue.end && new Date(filterValue.end).getTime() < itemTime) return false;
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!filterValue) return true;
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return String(itemValue) === filterValue;
|
||||
}
|
||||
|
||||
if (field.type === "array" && Array.isArray(itemValue) && field.relation) {
|
||||
const dispFields = getFilterDisplayFields(field);
|
||||
return itemValue.some((el: any) =>
|
||||
dispFields.some((df) => String(el[df]) === String(filterValue))
|
||||
);
|
||||
}
|
||||
|
||||
if (field.relation && itemValue && typeof itemValue === "object") {
|
||||
const dispFields = getFilterDisplayFields(field);
|
||||
return dispFields.some((df) => String(itemValue[df]) === String(filterValue));
|
||||
}
|
||||
|
||||
return String(itemValue) === String(filterValue);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
|
||||
const { id } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
const isCreate = location.pathname.endsWith('/create');
|
||||
const isEdit = location.pathname.includes('/edit/');
|
||||
const isView = !!id && !isEdit;
|
||||
const isList = !id && !isCreate;
|
||||
|
||||
const isServer = config.mode !== "client";
|
||||
|
||||
const [paginationModel, setPaginationModel] = React.useState<GridPaginationModel>({
|
||||
page: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({});
|
||||
|
||||
const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
|
||||
|
||||
// Determine query parameters based on pagination config
|
||||
const queryParams = React.useMemo(() => {
|
||||
if (!config.pagination) return {};
|
||||
if (!isServer) return { limit: 10000 };
|
||||
return {
|
||||
skip: paginationModel.page * paginationModel.pageSize,
|
||||
limit: paginationModel.pageSize,
|
||||
};
|
||||
}, [config.pagination, paginationModel]);
|
||||
}, [isServer, paginationModel]);
|
||||
|
||||
const listQuery = useList(queryParams);
|
||||
const itemQuery = useRead(id || "");
|
||||
|
||||
const paginatedData = listQuery.data || { data: [], total: undefined };
|
||||
|
||||
const rawData = listQuery.data?.data || [];
|
||||
const totalCount = listQuery.data?.total;
|
||||
|
||||
const filteredData = React.useMemo(
|
||||
() => (isServer ? rawData : applyClientFilters(rawData, appliedFilters, config.fields)),
|
||||
[isServer, rawData, appliedFilters, config.fields]
|
||||
);
|
||||
|
||||
const createMutation = useCreate();
|
||||
const updateMutation = useUpdate();
|
||||
const deleteMutation = useDelete();
|
||||
@@ -80,18 +157,31 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
|
||||
return (
|
||||
<Box>
|
||||
{isList ? (
|
||||
<EnhancedTable
|
||||
config={config}
|
||||
data={paginatedData.data || []}
|
||||
total={paginatedData.total}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
loading={listQuery.isFetching}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCreate={handleCreate}
|
||||
onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)}
|
||||
/>
|
||||
<Box>
|
||||
{!isServer && config.filterableFields && config.filterableFields.length > 0 && (
|
||||
<FilterBar
|
||||
fields={config.fields}
|
||||
filterableFields={config.filterableFields!}
|
||||
mode={config.mode}
|
||||
data={rawData}
|
||||
appliedValues={appliedFilters}
|
||||
onApply={setAppliedFilters}
|
||||
onClear={() => setAppliedFilters({})}
|
||||
/>
|
||||
)}
|
||||
<EnhancedTable
|
||||
config={config}
|
||||
data={filteredData}
|
||||
total={isServer ? totalCount : filteredData.length}
|
||||
paginationModel={isServer ? paginationModel : undefined}
|
||||
onPaginationModelChange={isServer ? setPaginationModel : undefined}
|
||||
loading={listQuery.isFetching}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCreate={handleCreate}
|
||||
onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Paper sx={{ p: 4 }}>
|
||||
<GenericForm
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export { default as Admin } from "./Admin";
|
||||
export { api, auth, initializeApiClients } from "./api/client";
|
||||
export { getAppConfig } from "./config";
|
||||
export type { AppConfig, ResourceConfig, ResourceField } from "./types/config";
|
||||
export type { AppConfig, ResourceConfig, ResourceField, ResourceMode } from "./types/config";
|
||||
export { AppProvider } from "./providers/AppProvider";
|
||||
export { ConfigContext, useConfig } from "./providers/ConfigContext";
|
||||
export { useResource, useResourceByName } from "./hooks/useResource";
|
||||
export { default as FilterBar } from "./components/FilterBar";
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface ResourceField {
|
||||
relation?: string; // Name of the target resource
|
||||
}
|
||||
|
||||
export type ResourceMode = "server" | "client";
|
||||
|
||||
export interface ResourceConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
@@ -31,6 +33,8 @@ export interface ResourceConfig {
|
||||
fields: Record<string, ResourceField>;
|
||||
pagination?: boolean;
|
||||
hidden?: boolean;
|
||||
mode: ResourceMode;
|
||||
filterableFields?: string[];
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
|
||||
@@ -13,4 +13,6 @@ export interface ResourceOverride {
|
||||
fields?: Record<string, FieldOverride>;
|
||||
pagination?: boolean;
|
||||
hidden?: boolean;
|
||||
mode?: "server" | "client";
|
||||
filterableFields?: string[];
|
||||
}
|
||||
|
||||
@@ -159,10 +159,12 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
|
||||
label: schema.title || label,
|
||||
pluralLabel: pluralLabel,
|
||||
endpoint: listPath,
|
||||
primaryKey: "id", // Strict default, no heuristics
|
||||
primaryKey: "id",
|
||||
fields,
|
||||
pagination: resourceOverride.pagination,
|
||||
hidden: resourceOverride.hidden,
|
||||
mode: resourceOverride.mode || "server",
|
||||
filterableFields: resourceOverride.filterableFields,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { ResourceOverride } from "../react-openapi/types/overrides";
|
||||
|
||||
export const configuration: Record<string, ResourceOverride> = {
|
||||
expenses: {
|
||||
mode: "client",
|
||||
filterableFields: ["payee", "account", "tags", "occurred_at", "amount"],
|
||||
fields: {
|
||||
payee: {
|
||||
displayField: "name",
|
||||
@@ -38,11 +40,7 @@ export const configuration: Record<string, ResourceOverride> = {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
pagination: true,
|
||||
},
|
||||
// reports: {
|
||||
// hidden: true
|
||||
// }
|
||||
};
|
||||
|
||||
export const profileConfiguration = {
|
||||
|
||||
Reference in New Issue
Block a user