4 Commits
1.1.1 ... 1.1.2

Author SHA1 Message Date
a227c14e0a removed displayField 2026-06-08 01:03:00 +05:30
58df11c623 refactor: replace all displayField usages with displayFormat 2026-06-08 00:55:54 +05:30
9771816cf9 getDisplayString fix to use displayFormat 2026-06-08 00:37:17 +05:30
7bd946ec7a Refactor the React OpenAPI admin framework to support fully customizable field rendering and UI composition. (#11)
# Summary

Refactor the React OpenAPI admin framework to support fully customizable field rendering and UI composition.

## Changes

### Admin UI Customization

* Added support for custom:

  * Dashboard component
  * Layout component
  * Login page component
* Introduced `AdminAppProps` and extended `Admin` configuration API.
* Renamed internal dashboard implementation to `DefaultDashboard`.

### Field Component Architecture

* Extracted field rendering into dedicated field components:

  * TextField
  * NumberField
  * BooleanField
  * DateField
  * EnumField
  * RelationField
  * ObjectField
  * FallbackField
  * DateRangeField
  * NumberRangeField
* Added `defaultFieldComponents` registry.
* Refactored `FormField` to resolve components dynamically from a component map instead of hardcoded field type handling.

### Resource Customization

* Added `FieldComponents` support across:

  * Admin
  * ResourceView
  * GenericForm
  * useResource
* Introduced wrapped `FormField` and `GenericForm` components generated from configured field overrides.

### Table Customization

* Added `EnhancedTableComponents`.
* Added support for custom cell renderers per field type.
* Enabled custom rendering for both desktop and mobile table layouts.

### Filter Improvements

* Exported `FilterAutocomplete`.
* Added support for custom date-range and number-range filter components.
* Added filter component extension points.
* Updated filter option label resolution to support `displayFormat`.

### Display Formatting

* Replaced `displayField` usage with `displayFormat`.
* Added template-based display rendering support through `resolveTemplate`.
* Improved relation display configuration handling.

### TypeScript Improvements

* Added TypeScript as a project dependency.
* Removed multiple `@ts-ignore` usages.
* Added strongly typed Axios wrapper methods with generic response support.
* Improved typing across hooks and component interfaces.

### OpenAPI Configuration Validation

* Added validation for enum fields without enum values.
* Added validation for relation resources missing `referenceOptions.enumOption`.
* Improved relation metadata propagation during schema parsing.

### Library Exports

* Exported:

  * Field component types
  * Override types
  * EnhancedTable
  * GenericForm
  * ResourceView
  * Field components and defaults
* Expanded public API surface for consumers extending the framework.

## Benefits

* Enables complete UI customization without modifying framework internals.
* Simplifies creation of custom field types and renderers.
* Improves type safety and developer experience.
* Provides consistent extension points for forms, tables, filters, and admin layouts.
* Makes the framework more suitable for reusable library distribution.

Reviewed-on: #11
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-06-07 12:35:52 +00:00
7 changed files with 224 additions and 25 deletions

40
.dockerignore Normal file
View File

@@ -0,0 +1,40 @@
# Node modules
node_modules
**/node_modules
# Logs
*.log
logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
build
dist
out
.next
.cache
.parcel-cache
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# OS files
.DS_Store
Thumbs.db
# IDE / Editor folders
.vscode
.idea
*.sublime-workspace
*.sublime-project
# Temporary files
*.swp
*.bak
*.tmp

140
.drone.yml Normal file
View File

@@ -0,0 +1,140 @@
---
kind: pipeline
type: docker
name: default
platform:
os: linux
arch: arm64
workspace:
path: /drone/src
volumes:
- name: dockersock
host:
path: /var/run/docker.sock
steps:
- name: fetch-tags
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- apk add --no-cache git
- git fetch --tags
- |
# Get latest Git tag and trim newline
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null | tr -d '\n')
echo "Latest Git tag fetched: $LATEST_TAG"
# Save to file for downstream steps
echo "$LATEST_TAG" > /drone/src/LATEST_TAG.txt
# Read back for verification
IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
echo "Image tag read from file: $IMAGE_TAG"
# Validate
if [ -z "$IMAGE_TAG" ]; then
echo "❌ No git tags found! Cannot continue."
exit 1
fi
- name: check-remote-image
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "Checking if apps/khata:$IMAGE_TAG exists on remote Docker..."
- echo "Existing Docker tags for apps/khata:"
- docker images --format "{{.Repository}}:{{.Tag}}" | grep "^apps/khata" || echo "(none)"
- |
if docker image inspect apps/khata:$IMAGE_TAG > /dev/null 2>&1; then
echo "✅ Docker image apps/khata:$IMAGE_TAG already exists — skipping build"
exit 78
else
echo "⚙️ Docker image apps/khata:$IMAGE_TAG not found — proceeding to build..."
fi
- name: build-image
image: docker:24
environment:
API_BASE_URL:
from_secret: API_BASE_URL
AUTH_BASE_URL:
from_secret: AUTH_BASE_URL
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "🔨 Building Docker image apps/khata:$IMAGE_TAG ..."
- |
docker build --network=host \
--build-arg VITE_API_BASE_URL="$API_BASE_URL" \
--build-arg VITE_AUTH_BASE_URL="$AUTH_BASE_URL" \
-t apps/khata:$IMAGE_TAG \
-t apps/khata:latest \
/drone/src
- name: push-image
image: docker:24
environment:
REGISTRY_HOST:
from_secret: REGISTRY_HOST
REGISTRY_USER:
from_secret: REGISTRY_USER
REGISTRY_PASS:
from_secret: REGISTRY_PASS
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "🔑 Logging into registry $REGISTRY_HOST ..."
- echo "$REGISTRY_PASS" | docker login $REGISTRY_HOST -u "$REGISTRY_USER" --password-stdin
- echo "🏷️ Tagging images with registry prefix..."
- docker tag apps/khata:$IMAGE_TAG $REGISTRY_HOST/apps/khata:$IMAGE_TAG
- docker tag apps/khata:$IMAGE_TAG $REGISTRY_HOST/apps/khata:latest
- echo "📤 Pushing apps/khata:$IMAGE_TAG ..."
- docker push $REGISTRY_HOST/apps/khata:$IMAGE_TAG
- echo "📤 Pushing apps/khata:latest ..."
- docker push $REGISTRY_HOST/apps/khata:latest
- name: stop-old
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- echo "🛑 Stopping old container..."
- docker rm -f khata || true
- name: run-container
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "🚀 Starting container apps/khata:$IMAGE_TAG ..."
- |
docker run -d \
--name khata \
-p 3002:3000 \
-e NODE_ENV=production \
--restart always \
apps/khata:$IMAGE_TAG
# Trigger rules
trigger:
event:
- tag

33
Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# Stage 1: Build
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package.json and package-lock.json (or yarn.lock)
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy the rest of the app
COPY . .
# Build the app
ARG VITE_API_BASE_URL
ARG VITE_AUTH_BASE_URL
RUN VITE_API_BASE_URL=$VITE_API_BASE_URL VITE_AUTH_BASE_URL=$VITE_AUTH_BASE_URL npm run build
# Stage 2: Static file server (BusyBox)
FROM busybox:latest
WORKDIR /app
# Copy only build frontend files
COPY --from=builder /app/dist /app
# Expose port
EXPOSE 3000
# Default command
CMD ["busybox", "httpd", "-f", "-p", "3000"]

View File

@@ -278,19 +278,10 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components
);
}
function getFormattedDisplayValue(item: any, displayField?: string | string[], enumValue?: string) {
function getFormattedDisplayValue(item: any, displayFormat: string) {
if (!item) return "";
if (enumValue) return resolveTemplate(enumValue, item);
if (!displayField) return item.name || item.title || item.label || item.id || JSON.stringify(item);
if (Array.isArray(displayField)) {
return displayField
.map(key => item[key])
.filter(val => val !== undefined && val !== null)
.join(' ');
}
return item[displayField] || item.id || JSON.stringify(item);
return resolveTemplate(displayFormat, item);
}
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile, components }: any) {
@@ -307,7 +298,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
// 1. Single Relation
if (field.relation && value && !Array.isArray(value)) {
const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value;
const displayValue = getFormattedDisplayValue(value, field.displayField, field.enumOption?.value);
const displayValue = getFormattedDisplayValue(value, field.displayFormat);
return (
<Chip
@@ -327,7 +318,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
// 2. Multi-Select (Array of relations or simple strings)
if (field.type === 'array' && Array.isArray(value)) {
const enumValue = field.enumOption?.value;
const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayField, enumValue)).join(', ');
const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayFormat)).join(', ');
return (
<Tooltip title={tooltipTitle} arrow placement="top">
@@ -335,7 +326,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
{value.map((item, idx) => (
<Chip
key={idx}
label={getFormattedDisplayValue(item, field.displayField, enumValue)}
label={getFormattedDisplayValue(item, field.displayFormat)}
size="small"
variant="filled"
sx={{ maxWidth: 120 }}
@@ -355,7 +346,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
// 3. Simple Objects
if (field.type === 'object' && value) {
return getFormattedDisplayValue(value, field.displayField, field.enumOption?.value) || (isMobile ? 'Object' : JSON.stringify(value));
return getFormattedDisplayValue(value, field.displayFormat) || (isMobile ? 'Object' : JSON.stringify(value));
}
if (field.type === 'number' && typeof value === 'number') {

View File

@@ -124,7 +124,7 @@ function extractOptions(
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item);
// Use displayFormat if defined, otherwise fall back to displayField logic (for backward compatibility)
// Use displayFormat if defined
if (field.displayFormat) {
return resolveTemplate(field.displayFormat, item);
}

View File

@@ -20,13 +20,8 @@ import { GridPaginationModel } from '@mui/x-data-grid';
function getDisplayString(item: any, field: ResourceField): string {
if (item == null || typeof item !== 'object') return String(item ?? '');
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item);
const df = field.displayField;
if (!df) return item.name ?? item.title ?? item.label ?? item.id ?? JSON.stringify(item);
if (Array.isArray(df)) {
const parts = df.map((k: string) => item[k]).filter((v: any) => v != null);
return parts.length > 0 ? parts.join(' ') : '';
}
return String(item[df] ?? '');
if (field.displayFormat) return resolveTemplate(field.displayFormat, item);
throw new Error('cannot get display string')
}
function applyClientFilters(
@@ -119,7 +114,7 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
const { useList, useRead, useCreate, useUpdate, useDelete, components } = useResource(config, { fieldComponents });
const queryParams = React.useMemo(() => {
if (!isServer) return { limit: 10 };
if (!isServer) return { limit: 10000 };
return {
skip: paginationModel.page * paginationModel.pageSize,
limit: paginationModel.pageSize,

View File

@@ -26,7 +26,7 @@ export function getFieldOptions(field: ResourceField, relationData?: any[]): Sel
}
return data.map(item => ({
key: String(item[enumOption.key] ?? ''),
key: String(item[enumOption.key]),
value: resolveTemplate(enumOption.value, item),
}));
}