diff --git a/src_generic/App.tsx b/src_generic/App.tsx index fff325e..c31f1bf 100644 --- a/src_generic/App.tsx +++ b/src_generic/App.tsx @@ -50,6 +50,12 @@ function AdminApp() { const [selectedResourceName, setSelectedResourceName] = React.useState< string | null >(null); + const [selectedItemId, setSelectedItemId] = React.useState(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 ? ( - + ) : ( )} diff --git a/src_generic/components/EnhancedTable.tsx b/src_generic/components/EnhancedTable.tsx new file mode 100644 index 0000000..290ae9d --- /dev/null +++ b/src_generic/components/EnhancedTable.tsx @@ -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 ( + { + e.stopPropagation(); + onNavigateToResource?.(field.relation!, relationId); + }} + > + {relationId} + + ); + } + } + + // 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) => [ + } + label="Edit" + onClick={() => onEdit(params.row)} + />, + } + label="Delete" + onClick={() => onDelete(params.id as string)} + />, + ], + }); + + return cols; + }, [config, onEdit, onDelete, onNavigateToResource]); + + return ( + + + {config.pluralLabel} + + + { + 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]} + /> + + ); +} diff --git a/src_generic/components/GenericTable.tsx b/src_generic/components/GenericTable.tsx deleted file mode 100644 index 410e24c..0000000 --- a/src_generic/components/GenericTable.tsx +++ /dev/null @@ -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 ( - - - {config.pluralLabel} - - - - - - - - {fields.map(([key, field]) => ( - {field.label} - ))} - Actions - - - - {data.map((item) => ( - - {fields.map(([key, field]) => ( - - {renderCellValue(item[key], field)} - - ))} - - onEdit(item)}> - - - onDelete(item[config.primaryKey])}> - - - - - ))} - -
-
-
- ); -} - -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); - } -} diff --git a/src_generic/components/ResourceView.tsx b/src_generic/components/ResourceView.tsx index 94e09de..1452bdf 100644 --- a/src_generic/components/ResourceView.tsx +++ b/src_generic/components/ResourceView.tsx @@ -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(null); @@ -56,12 +57,13 @@ export default function ResourceView({ config }: ResourceViewProps) { return ( {view === 'list' ? ( - ) : ( diff --git a/src_generic/configuration.ts b/src_generic/configuration.ts new file mode 100644 index 0000000..da4dfa9 --- /dev/null +++ b/src_generic/configuration.ts @@ -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; +} + +export const configuration: Record = { + 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}`; + } + } + }, + }, +}; diff --git a/src_generic/expenses_openapi.yaml b/src_generic/expenses_openapi.yaml deleted file mode 100644 index f9478a9..0000000 --- a/src_generic/expenses_openapi.yaml +++ /dev/null @@ -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 diff --git a/src_generic/types/config.ts b/src_generic/types/config.ts index 62a9f4a..4b7b59b 100644 --- a/src_generic/types/config.ts +++ b/src_generic/types/config.ts @@ -14,9 +14,12 @@ export interface ResourceField { type: FieldType; label: string; required?: boolean; - options?: string[]; // for enum - schema?: Record; // for object or array items + options?: string[]; readOnly?: boolean; + schema?: Record; + displayField?: string; + formatter?: (value: any) => string; + relation?: string; // Name of the target resource } export interface ResourceConfig { diff --git a/src_generic/utils/openapi_loader.ts b/src_generic/utils/openapi_loader.ts index 4791e53..1dce7c5 100644 --- a/src_generic/utils/openapi_loader.ts +++ b/src_generic/utils/openapi_loader.ts @@ -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 { +function parseSchemaFields( + schema: any, + resourceName: string, + allResources: string[] +): Record { const fields: Record = {}; 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); } } @@ -70,13 +112,15 @@ export async function loadConfigFromOpenApi(baseUrl: string): Promise if (!resourcePaths[base]) resourcePaths[base] = { path, methods: [] }; const methods = Object.keys(paths[path] || {}); resourcePaths[base].methods.push(...methods); - + // We prefer the plural GET path for schema extraction if (!path.includes("{") && paths[path]?.get?.responses?.["200"]) { - resourcePaths[base].listPath = path; + resourcePaths[base].listPath = path; } } + 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 // 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 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, };