diff --git a/react-openapi/components/EnhancedTable.tsx b/react-openapi/components/EnhancedTable.tsx index 9e00a1c..434a4cc 100644 --- a/react-openapi/components/EnhancedTable.tsx +++ b/react-openapi/components/EnhancedTable.tsx @@ -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({ + 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} diff --git a/react-openapi/components/FilterBar.tsx b/react-openapi/components/FilterBar.tsx new file mode 100644 index 0000000..bf8af63 --- /dev/null +++ b/react-openapi/components/FilterBar.tsx @@ -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(); + + 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 ( + + + {field.label} + + onChange("start", e.target.value || undefined)} + InputLabelProps={{ shrink: true }} + sx={{ width: 190 }} + /> + onChange("end", e.target.value || undefined)} + InputLabelProps={{ shrink: true }} + sx={{ width: 190 }} + /> + + ); + } + + return ( + + + {field.label} + + onChange("min", e.target.value || undefined)} + sx={{ width: 120 }} + /> + onChange("max", e.target.value || undefined)} + sx={{ width: 120 }} + /> + + ); + } + + if (field.type === "boolean") { + return ( + + {field.label} + + + ); + } + + if (options.length <= 20) { + return ( + onChange("value", val || undefined)} + renderInput={(params) => ( + + )} + sx={{ minWidth: 180 }} + size="small" + /> + ); + } + + return ( + onChange("value", e.target.value || undefined)} + size="small" + sx={{ minWidth: 180 }} + /> + ); +} + +export interface FilterBarProps { + fields: Record; + filterableFields: string[]; + mode: ResourceMode; + data?: any[]; + appliedValues: Record; + onApply: (values: Record) => 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>(() => ({ ...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 ( + + setOpen((o) => !o)} + > + + + + {open ? "Hide Filters" : "Show Filters"} + + + {activeCount > 0 && ( + + {activeCount} active + + )} + + + {open && ( + + + {filterableFields.map((fieldName) => { + const field = fields[fieldName]; + if (!field) return null; + + const options = extractOptions(fieldName, field, data ?? []); + const raw = draft[fieldName]; + + return ( + + {renderFilterInput(fieldName, field, options, raw, (key, val) => + updateDraft(fieldName, key, val) + )} + + ); + })} + + + + + + + + )} + + ); +} diff --git a/react-openapi/components/ResourceView.tsx b/react-openapi/components/ResourceView.tsx index f770d45..4c69014 100644 --- a/react-openapi/components/ResourceView.tsx +++ b/react-openapi/components/ResourceView.tsx @@ -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, + fields: Record +): 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({ page: 0, pageSize: 10, }); + const [appliedFilters, setAppliedFilters] = React.useState>({}); + 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 ( {isList ? ( - navigate(`/admin/${res}/${id}`)} - /> + + {!isServer && config.filterableFields && config.filterableFields.length > 0 && ( + setAppliedFilters({})} + /> + )} + navigate(`/admin/${res}/${id}`)} + /> + ) : ( ; pagination?: boolean; hidden?: boolean; + mode: ResourceMode; + filterableFields?: string[]; } export interface AppConfig { diff --git a/react-openapi/types/overrides.ts b/react-openapi/types/overrides.ts index 6a37d10..bb11784 100644 --- a/react-openapi/types/overrides.ts +++ b/react-openapi/types/overrides.ts @@ -13,4 +13,6 @@ export interface ResourceOverride { fields?: Record; pagination?: boolean; hidden?: boolean; + mode?: "server" | "client"; + filterableFields?: string[]; } diff --git a/react-openapi/utils/openapi_loader.ts b/react-openapi/utils/openapi_loader.ts index 112f74c..cd91a1d 100644 --- a/react-openapi/utils/openapi_loader.ts +++ b/react-openapi/utils/openapi_loader.ts @@ -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, }); } diff --git a/src/openapi-config.ts b/src/openapi-config.ts index 7af32b0..d837480 100644 --- a/src/openapi-config.ts +++ b/src/openapi-config.ts @@ -2,6 +2,8 @@ import { ResourceOverride } from "../react-openapi/types/overrides"; export const configuration: Record = { expenses: { + mode: "client", + filterableFields: ["payee", "account", "tags", "occurred_at", "amount"], fields: { payee: { displayField: "name", @@ -38,11 +40,7 @@ export const configuration: Record = { display: false } }, - pagination: true, }, - // reports: { - // hidden: true - // } }; export const profileConfiguration = {