configuration for how fields look and EnhancedTable component for enhanced table display
This commit is contained in:
@@ -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 />
|
||||
)}
|
||||
|
||||
158
src_generic/components/EnhancedTable.tsx
Normal file
158
src_generic/components/EnhancedTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }}>
|
||||
|
||||
50
src_generic/configuration.ts
Normal file
50
src_generic/configuration.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,13 +112,15 @@ export async function loadConfigFromOpenApi(baseUrl: string): Promise<AppConfig>
|
||||
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<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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user