configuration for how fields look and EnhancedTable component for enhanced table display

This commit is contained in:
2026-04-01 18:47:23 +05:30
parent 344106f1a4
commit 44567496a1
8 changed files with 298 additions and 512 deletions

View File

@@ -50,6 +50,12 @@ function AdminApp() {
const [selectedResourceName, setSelectedResourceName] = React.useState<
string | null
>(null);
const [selectedItemId, setSelectedItemId] = React.useState<string | null>(null);
const handleNavigateToResource = (resourceName: string, id: string) => {
setSelectedResourceName(resourceName);
setSelectedItemId(id);
};
if (!currentUser) {
return (
@@ -75,11 +81,18 @@ function AdminApp() {
username={currentUser.username}
onLogout={logout}
selectedResourceName={selectedResourceName}
onSelectResource={setSelectedResourceName}
onSelectResource={(name) => {
setSelectedResourceName(name);
setSelectedItemId(null);
}}
resources={config?.resources || []}
>
{selectedResource ? (
<ResourceView key={selectedResource.name} config={selectedResource} />
<ResourceView
key={`${selectedResource.name}-${selectedItemId}`}
config={selectedResource}
onNavigateToResource={handleNavigateToResource}
/>
) : (
<Dashboard />
)}

View File

@@ -0,0 +1,158 @@
import * as React from 'react';
import {
Box,
Typography,
Button,
IconButton,
Link,
Tooltip,
} from '@mui/material';
import {
DataGrid,
GridColDef,
GridActionsCellItem,
GridRenderCellParams,
} from '@mui/x-data-grid';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { ResourceConfig, ResourceField } from '../types/config';
interface EnhancedTableProps {
config: ResourceConfig;
data: any[];
onEdit: (item: any) => void;
onDelete: (id: string) => void;
onCreate: () => void;
onNavigateToResource?: (resourceName: string, id: string) => void;
}
export default function EnhancedTable({
config,
data,
onEdit,
onDelete,
onCreate,
onNavigateToResource,
}: EnhancedTableProps) {
const columns: GridColDef[] = React.useMemo(() => {
const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => {
const col: GridColDef = {
field: key,
headerName: field.label,
flex: 1,
minWidth: 150,
renderCell: (params: GridRenderCellParams) => {
const value = params.value;
// 1. Custom Formatter
if (field.formatter) {
return field.formatter(value);
}
// 2. Relational Link
if (field.relation && value) {
const relationId = typeof value === 'object' ? value.id : value;
if (relationId) {
return (
<Link
component="button"
variant="body2"
onClick={(e) => {
e.stopPropagation();
onNavigateToResource?.(field.relation!, relationId);
}}
>
{relationId}
</Link>
);
}
}
// 3. Nested Object / Array Display
if (field.type === 'array' && Array.isArray(value)) {
if (field.displayField) {
return value
.map((item) => (typeof item === 'object' ? item[field.displayField!] : item))
.filter(Boolean)
.join(', ');
}
return `${value.length} items`;
}
if (field.type === 'object' && value) {
if (field.displayField && value[field.displayField]) {
return value[field.displayField];
}
return JSON.stringify(value);
}
// 4. Default renderings
if (field.type === 'boolean') return value ? 'Yes' : 'No';
if (field.type === 'datetime' || field.type === 'date') {
return new Date(value).toLocaleString();
}
return value;
}
};
return col;
});
cols.push({
field: 'actions',
type: 'actions',
headerName: 'Actions',
width: 100,
getActions: (params) => [
<GridActionsCellItem
icon={<EditIcon />}
label="Edit"
onClick={() => onEdit(params.row)}
/>,
<GridActionsCellItem
icon={<DeleteIcon />}
label="Delete"
onClick={() => onDelete(params.id as string)}
/>,
],
});
return cols;
}, [config, onEdit, onDelete, onNavigateToResource]);
return (
<Box sx={{ height: 600, width: '100%' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, alignItems: 'center' }}>
<Typography variant="h5">{config.pluralLabel}</Typography>
<Button variant="contained" color="primary" onClick={onCreate}>
Add {config.label}
</Button>
</Box>
<DataGrid
rows={data || []}
columns={columns}
getRowId={(row) => {
const pk = config.primaryKey;
if (row[pk] !== undefined && row[pk] !== null) return row[pk];
// Fallback: search for common ID fields
const fallbackKeys = ['id', 'uuid', 'pk'];
for (const key of fallbackKeys) {
if (row[key] !== undefined && row[key] !== null) return row[key];
}
debugger;
// Absolute fallback: index (not ideal but avoids crash)
return `temp-id-${data.indexOf(row)}`;
}}
disableRowSelectionOnClick
initialState={{
pagination: {
paginationModel: { page: 0, pageSize: 10 },
},
}}
pageSizeOptions={[5, 10, 25]}
/>
</Box>
);
}

View File

@@ -1,95 +0,0 @@
import * as React from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
IconButton,
Typography,
Box,
Button
} from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { ResourceConfig } from '../types/config';
interface GenericTableProps {
config: ResourceConfig;
data: any[];
onEdit: (item: any) => void;
onDelete: (id: string) => void;
onCreate: () => void;
}
export default function GenericTable({
config,
data,
onEdit,
onDelete,
onCreate,
}: GenericTableProps) {
const fields = Object.entries(config.fields);
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, alignItems: 'center' }}>
<Typography variant="h5">{config.pluralLabel}</Typography>
<Button variant="contained" color="primary" onClick={onCreate}>
Add {config.label}
</Button>
</Box>
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }}>
<TableHead>
<TableRow>
{fields.map(([key, field]) => (
<TableCell key={key}>{field.label}</TableCell>
))}
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.map((item) => (
<TableRow key={item[config.primaryKey]}>
{fields.map(([key, field]) => (
<TableCell key={key}>
{renderCellValue(item[key], field)}
</TableCell>
))}
<TableCell align="right">
<IconButton onClick={() => onEdit(item)}>
<EditIcon />
</IconButton>
<IconButton onClick={() => onDelete(item[config.primaryKey])}>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);
}
function renderCellValue(value: any, field: any) {
if (value === null || value === undefined) return '-';
switch (field.type) {
case 'boolean':
return value ? 'Yes' : 'No';
case 'date':
return new Date(value).toLocaleDateString();
case 'object':
return JSON.stringify(value);
case 'array':
return `${value.length} items`;
default:
return String(value);
}
}

View File

@@ -1,15 +1,16 @@
import * as React from 'react';
import { Box, Typography, Button, Paper, CircularProgress } from '@mui/material';
import { Box, Typography, Paper, CircularProgress } from '@mui/material';
import { ResourceConfig } from '../types/config';
import { useResource } from '../hooks/useResource';
import GenericTable from './GenericTable';
import GenericForm from './GenericForm';
import EnhancedTable from './EnhancedTable';
interface ResourceViewProps {
config: ResourceConfig;
onNavigateToResource?: (resourceName: string, id: string) => void;
}
export default function ResourceView({ config }: ResourceViewProps) {
export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
const [view, setView] = React.useState<'list' | 'create' | 'edit'>('list');
const [selectedItem, setSelectedItem] = React.useState<any>(null);
@@ -56,12 +57,13 @@ export default function ResourceView({ config }: ResourceViewProps) {
return (
<Box>
{view === 'list' ? (
<GenericTable
<EnhancedTable
config={config}
data={data || []}
onEdit={handleEdit}
onDelete={handleDelete}
onCreate={handleCreate}
onNavigateToResource={onNavigateToResource}
/>
) : (
<Paper sx={{ p: 4 }}>

View File

@@ -0,0 +1,50 @@
/**
* This file contains application-specific overrides and configuration
* for the generic Admin Panel.
*/
export interface FieldOverride {
displayField?: string;
formatter?: (value: any) => string;
}
export interface ResourceOverride {
fields?: Record<string, FieldOverride>;
}
export const configuration: Record<string, ResourceOverride> = {
expenses: {
fields: {
payee: {
displayField: "name",
},
payor: {
displayField: "username",
},
account: {
displayField: "nickname",
},
tags: {
displayField: "name",
},
occurred_at: {
formatter: (val: string) => {
const date = new Date(val);
const day = date.getDate();
const month = date.toLocaleString('default', { month: 'long' });
const year = date.getFullYear();
const suffix = (day: number) => {
if (day > 3 && day < 21) return 'th';
switch (day % 10) {
case 1: return "st";
case 2: return "nd";
case 3: return "rd";
default: return "th";
}
};
return `${day}${suffix(day)} ${month} ${year}`;
}
}
},
},
};

View File

@@ -1,391 +0,0 @@
openapi: 3.0.3
info:
title: Expenses Service
version: "1.0.0"
description: OpenAPI-first service for tracking expenses and cashflow.
paths:
/expenses:
post:
operationId: create_expense
summary: Create a new expense or income entry
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ExpenseInput"
responses:
"201":
description: Expense created
content:
application/json:
schema:
$ref: "#/components/schemas/Expense"
get:
operationId: list_expenses
summary: List expenses
parameters:
- name: from
in: query
schema:
type: string
format: date-time
- name: to
in: query
schema:
type: string
format: date-time
responses:
"200":
description: List of expenses
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Expense"
/expenses/{expense_id}:
get:
operationId: get_expense
summary: Get expense by ID
parameters:
- name: expense_id
in: path
required: true
schema:
type: string
responses:
"200":
description: Expense found
content:
application/json:
schema:
$ref: "#/components/schemas/Expense"
"404":
description: Expense not found
/accounts:
post:
operationId: create_account
summary: Create an account
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AccountCreate"
responses:
"201":
description: Account created
content:
application/json:
schema:
$ref: "#/components/schemas/Account"
get:
operationId: list_accounts
summary: List accounts
responses:
"200":
description: List of accounts
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Account"
/accounts/{account_id}:
get:
operationId: get_account
summary: Get account by ID
parameters:
- name: account_id
in: path
required: true
schema:
type: string
responses:
"200":
description: Account found
content:
application/json:
schema:
$ref: "#/components/schemas/Account"
"404":
description: Account not found
put:
operationId: update_account
summary: Update an account
parameters:
- name: account_id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AccountUpdate"
responses:
"200":
description: Account updated
content:
application/json:
schema:
$ref: "#/components/schemas/Account"
"404":
description: Account not found
delete:
operationId: delete_account
summary: Delete an account
parameters:
- name: account_id
in: path
required: true
schema:
type: string
responses:
"204":
description: Account deleted
"404":
description: Account not found
/tags:
post:
operationId: create_tag
summary: Create a tag
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TagCreate"
responses:
"201":
description: Tag created
content:
application/json:
schema:
$ref: "#/components/schemas/Tag"
get:
operationId: list_tags
summary: List tags
responses:
"200":
description: List of tags
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Tag"
/tags/{tag_id}:
get:
operationId: get_tag
summary: Get tag by ID
parameters:
- name: tag_id
in: path
required: true
schema:
type: string
responses:
"200":
description: Tag found
content:
application/json:
schema:
$ref: "#/components/schemas/Tag"
"404":
description: Tag not found
put:
operationId: update_tag
summary: Update a tag
parameters:
- name: tag_id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TagUpdate"
responses:
"200":
description: Tag updated
content:
application/json:
schema:
$ref: "#/components/schemas/Tag"
"404":
description: Tag not found
delete:
operationId: delete_tag
summary: Delete a tag
parameters:
- name: tag_id
in: path
required: true
schema:
type: string
responses:
"204":
description: Tag deleted
"404":
description: Tag not found
components:
schemas:
PublicUser:
type: object
required: [id, username]
properties:
id:
type: string
username:
type: string
email:
type: string
format: email
PayeeType:
type: string
enum: [merchant, person, transfer, other]
Payee:
type: object
required: [type, name]
properties:
type:
$ref: "#/components/schemas/PayeeType"
name:
type: string
AccountType:
type: string
enum: [cash, bank, credit_card, wallet, other]
Account:
type: object
required: [id, name, type, currency]
properties:
id:
type: string
name:
type: string
type:
$ref: "#/components/schemas/AccountType"
currency:
type: string
is_active:
type: boolean
AccountCreate:
type: object
required: [name, type, currency]
properties:
name:
type: string
type:
$ref: "#/components/schemas/AccountType"
currency:
type: string
is_active:
type: boolean
AccountUpdate:
allOf:
- $ref: "#/components/schemas/AccountCreate"
Tag:
type: object
required: [id, name]
properties:
id:
type: string
name:
type: string
parent_id:
type: string
nullable: true
TagCreate:
type: object
required: [name]
properties:
name:
type: string
parent_id:
type: string
nullable: true
TagUpdate:
allOf:
- $ref: "#/components/schemas/TagCreate"
ExpenseInput:
type: object
required:
- payor
- payee
- amount
- account
- occurred_at
properties:
payor:
type: string
payee:
$ref: "#/components/schemas/Payee"
amount:
type: number
format: float
account:
type: string
tags:
type: array
items:
type: string
occurred_at:
type: string
format: date-time
Expense:
type: object
required:
- id
- payor
- payee
- amount
- account
- occurred_at
- created_at
properties:
id:
type: string
payor:
$ref: "#/components/schemas/PublicUser"
payee:
$ref: "#/components/schemas/Payee"
amount:
type: number
format: float
account:
$ref: "#/components/schemas/Account"
tags:
type: array
items:
$ref: "#/components/schemas/Tag"
occurred_at:
type: string
format: date-time
created_at:
type: string
format: date-time

View File

@@ -14,9 +14,12 @@ export interface ResourceField {
type: FieldType;
label: string;
required?: boolean;
options?: string[]; // for enum
schema?: Record<string, ResourceField>; // for object or array items
options?: string[];
readOnly?: boolean;
schema?: Record<string, ResourceField>;
displayField?: string;
formatter?: (value: any) => string;
relation?: string; // Name of the target resource
}
export interface ResourceConfig {

View File

@@ -1,5 +1,6 @@
import SwaggerParser from "@apidevtools/swagger-parser";
import { AppConfig, ResourceConfig, ResourceField, FieldType } from "../types/config";
import { configuration } from "../configuration";
/**
* Maps OpenAPI property types to our internal FieldType
@@ -11,37 +12,78 @@ function mapOpenApiType(prop: any): FieldType {
if (format === "date-time") return "datetime";
if (format === "date") return "date";
if (prop.enum) return "enum";
if (type === "string" && (prop.description?.toLowerCase().includes("image") || prop.name?.toLowerCase().includes("icon"))) return "image";
if (
type === "string" &&
(prop.description?.toLowerCase().includes("image") ||
prop.name?.toLowerCase().includes("icon"))
)
return "image";
switch (type) {
case "integer":
case "number": return "number";
case "boolean": return "boolean";
case "object": return "object";
case "array": return "array";
default: return "string";
case "number":
return "number";
case "boolean":
return "boolean";
case "object":
return "object";
case "array":
return "array";
default:
return "string";
}
}
/**
* Recursively converts OpenAPI schemas to ResourceField map
*/
function parseSchemaFields(schema: any): Record<string, ResourceField> {
function parseSchemaFields(
schema: any,
resourceName: string,
allResources: string[]
): Record<string, ResourceField> {
const fields: Record<string, ResourceField> = {};
const properties = schema.properties || {};
const required = schema.required || [];
const overrides = configuration[resourceName]?.fields || {};
for (const [key, prop] of Object.entries(properties) as any) {
const type = mapOpenApiType(prop);
const override = overrides[key];
fields[key] = {
type: mapOpenApiType(prop),
label: prop.title || key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
type,
label:
prop.title ||
key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
required: required.includes(key),
options: prop.enum,
readOnly: prop.readOnly || key === "id" || key === "created_at" || key === "updated_at",
readOnly:
prop.readOnly ||
key === "id" ||
key === "created_at" ||
key === "updated_at",
...override,
};
// Schema-based Relation Detection
// If it's an object/string and matches a resource name, it might be a relation
const potentialRelation = allResources.find(
(res) =>
key === res ||
key === `${res}_id` ||
prop.title?.toLowerCase() === res ||
prop["x-resource"] === res
);
if (potentialRelation) {
if (type === "string" || (type === "object" && prop.properties?.id)) {
fields[key].relation = potentialRelation;
}
}
if (fields[key].type === "object" && prop.properties) {
fields[key].schema = parseSchemaFields(prop);
fields[key].schema = parseSchemaFields(prop, resourceName, allResources);
}
}
@@ -77,6 +119,8 @@ export async function loadConfigFromOpenApi(baseUrl: string): Promise<AppConfig>
}
}
const allResourceNames = Object.keys(resourcePaths);
// Generate ResourceConfig for each identified base path
for (const [name, info] of Object.entries(resourcePaths)) {
const listPath = info.listPath || `/${name}`;
@@ -89,7 +133,8 @@ export async function loadConfigFromOpenApi(baseUrl: string): Promise<AppConfig>
// Extract schema from the 200 response of the list endpoint
let schema: any = null;
const responseSchema = listOp.responses?.["200"]?.content?.["application/json"]?.schema;
const responseSchema =
listOp.responses?.["200"]?.content?.["application/json"]?.schema;
if (responseSchema?.type === "array" && responseSchema.items) {
schema = responseSchema.items;
@@ -103,14 +148,15 @@ export async function loadConfigFromOpenApi(baseUrl: string): Promise<AppConfig>
label: schema.title || label,
pluralLabel: pluralLabel,
endpoint: listPath,
primaryKey: "id", // assume 'id' as default or look for 'required' + 'unique'
fields: parseSchemaFields(schema),
primaryKey: "_id", // assume 'id' as default or look for 'required' + 'unique'
fields: parseSchemaFields(schema, name, allResourceNames),
});
}
}
return {
baseUrl: import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? ""),
baseUrl:
import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? ""),
authBaseUrl: import.meta.env.VITE_AUTH_BASE_URL || "",
resources,
};