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<
|
const [selectedResourceName, setSelectedResourceName] = React.useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [selectedItemId, setSelectedItemId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleNavigateToResource = (resourceName: string, id: string) => {
|
||||||
|
setSelectedResourceName(resourceName);
|
||||||
|
setSelectedItemId(id);
|
||||||
|
};
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
@@ -75,11 +81,18 @@ function AdminApp() {
|
|||||||
username={currentUser.username}
|
username={currentUser.username}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
selectedResourceName={selectedResourceName}
|
selectedResourceName={selectedResourceName}
|
||||||
onSelectResource={setSelectedResourceName}
|
onSelectResource={(name) => {
|
||||||
|
setSelectedResourceName(name);
|
||||||
|
setSelectedItemId(null);
|
||||||
|
}}
|
||||||
resources={config?.resources || []}
|
resources={config?.resources || []}
|
||||||
>
|
>
|
||||||
{selectedResource ? (
|
{selectedResource ? (
|
||||||
<ResourceView key={selectedResource.name} config={selectedResource} />
|
<ResourceView
|
||||||
|
key={`${selectedResource.name}-${selectedItemId}`}
|
||||||
|
config={selectedResource}
|
||||||
|
onNavigateToResource={handleNavigateToResource}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Dashboard />
|
<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 * 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 { ResourceConfig } from '../types/config';
|
||||||
import { useResource } from '../hooks/useResource';
|
import { useResource } from '../hooks/useResource';
|
||||||
import GenericTable from './GenericTable';
|
|
||||||
import GenericForm from './GenericForm';
|
import GenericForm from './GenericForm';
|
||||||
|
import EnhancedTable from './EnhancedTable';
|
||||||
|
|
||||||
interface ResourceViewProps {
|
interface ResourceViewProps {
|
||||||
config: ResourceConfig;
|
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 [view, setView] = React.useState<'list' | 'create' | 'edit'>('list');
|
||||||
const [selectedItem, setSelectedItem] = React.useState<any>(null);
|
const [selectedItem, setSelectedItem] = React.useState<any>(null);
|
||||||
|
|
||||||
@@ -56,12 +57,13 @@ export default function ResourceView({ config }: ResourceViewProps) {
|
|||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{view === 'list' ? (
|
{view === 'list' ? (
|
||||||
<GenericTable
|
<EnhancedTable
|
||||||
config={config}
|
config={config}
|
||||||
data={data || []}
|
data={data || []}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onCreate={handleCreate}
|
onCreate={handleCreate}
|
||||||
|
onNavigateToResource={onNavigateToResource}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Paper sx={{ p: 4 }}>
|
<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;
|
type: FieldType;
|
||||||
label: string;
|
label: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
options?: string[]; // for enum
|
options?: string[];
|
||||||
schema?: Record<string, ResourceField>; // for object or array items
|
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
schema?: Record<string, ResourceField>;
|
||||||
|
displayField?: string;
|
||||||
|
formatter?: (value: any) => string;
|
||||||
|
relation?: string; // Name of the target resource
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceConfig {
|
export interface ResourceConfig {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwaggerParser from "@apidevtools/swagger-parser";
|
import SwaggerParser from "@apidevtools/swagger-parser";
|
||||||
import { AppConfig, ResourceConfig, ResourceField, FieldType } from "../types/config";
|
import { AppConfig, ResourceConfig, ResourceField, FieldType } from "../types/config";
|
||||||
|
import { configuration } from "../configuration";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps OpenAPI property types to our internal FieldType
|
* 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-time") return "datetime";
|
||||||
if (format === "date") return "date";
|
if (format === "date") return "date";
|
||||||
if (prop.enum) return "enum";
|
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) {
|
switch (type) {
|
||||||
case "integer":
|
case "integer":
|
||||||
case "number": return "number";
|
case "number":
|
||||||
case "boolean": return "boolean";
|
return "number";
|
||||||
case "object": return "object";
|
case "boolean":
|
||||||
case "array": return "array";
|
return "boolean";
|
||||||
default: return "string";
|
case "object":
|
||||||
|
return "object";
|
||||||
|
case "array":
|
||||||
|
return "array";
|
||||||
|
default:
|
||||||
|
return "string";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively converts OpenAPI schemas to ResourceField map
|
* 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 fields: Record<string, ResourceField> = {};
|
||||||
const properties = schema.properties || {};
|
const properties = schema.properties || {};
|
||||||
const required = schema.required || [];
|
const required = schema.required || [];
|
||||||
|
const overrides = configuration[resourceName]?.fields || {};
|
||||||
|
|
||||||
for (const [key, prop] of Object.entries(properties) as any) {
|
for (const [key, prop] of Object.entries(properties) as any) {
|
||||||
|
const type = mapOpenApiType(prop);
|
||||||
|
const override = overrides[key];
|
||||||
|
|
||||||
fields[key] = {
|
fields[key] = {
|
||||||
type: mapOpenApiType(prop),
|
type,
|
||||||
label: prop.title || key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
|
label:
|
||||||
|
prop.title ||
|
||||||
|
key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
|
||||||
required: required.includes(key),
|
required: required.includes(key),
|
||||||
options: prop.enum,
|
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) {
|
if (fields[key].type === "object" && prop.properties) {
|
||||||
fields[key].schema = parseSchemaFields(prop);
|
fields[key].schema = parseSchemaFields(prop, resourceName, allResources);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,10 +115,12 @@ export async function loadConfigFromOpenApi(baseUrl: string): Promise<AppConfig>
|
|||||||
|
|
||||||
// We prefer the plural GET path for schema extraction
|
// We prefer the plural GET path for schema extraction
|
||||||
if (!path.includes("{") && paths[path]?.get?.responses?.["200"]) {
|
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
|
// Generate ResourceConfig for each identified base path
|
||||||
for (const [name, info] of Object.entries(resourcePaths)) {
|
for (const [name, info] of Object.entries(resourcePaths)) {
|
||||||
const listPath = info.listPath || `/${name}`;
|
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
|
// Extract schema from the 200 response of the list endpoint
|
||||||
let schema: any = null;
|
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) {
|
if (responseSchema?.type === "array" && responseSchema.items) {
|
||||||
schema = responseSchema.items;
|
schema = responseSchema.items;
|
||||||
@@ -103,14 +148,15 @@ export async function loadConfigFromOpenApi(baseUrl: string): Promise<AppConfig>
|
|||||||
label: schema.title || label,
|
label: schema.title || label,
|
||||||
pluralLabel: pluralLabel,
|
pluralLabel: pluralLabel,
|
||||||
endpoint: listPath,
|
endpoint: listPath,
|
||||||
primaryKey: "id", // assume 'id' as default or look for 'required' + 'unique'
|
primaryKey: "_id", // assume 'id' as default or look for 'required' + 'unique'
|
||||||
fields: parseSchemaFields(schema),
|
fields: parseSchemaFields(schema, name, allResourceNames),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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 || "",
|
authBaseUrl: import.meta.env.VITE_AUTH_BASE_URL || "",
|
||||||
resources,
|
resources,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user