docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Status;
use App\Framework\Router\Result\JsonResult;
/**
* Admin API Handler
*
* Handles auto-generated API endpoints for admin resources
*/
final readonly class AdminApiHandler
{
public function handleList(
HttpRequest $request,
mixed $repository
): JsonResult {
// Extract query parameters
$page = (int) ($request->query->get('page') ?? 1);
$perPage = (int) ($request->query->get('per_page') ?? 25);
$sortBy = $request->query->get('sort_by');
$sortDir = $request->query->get('sort_dir') ?? 'asc';
$search = $request->query->get('search');
// Build filters
$filters = [];
if ($search) {
$filters['search'] = $search;
}
// Fetch data from repository
$data = $repository->findAll($filters);
// Apply sorting if requested
if ($sortBy && method_exists($repository, 'sortBy')) {
$data = $repository->sortBy($data, $sortBy, $sortDir);
}
// Apply pagination
$total = count($data);
$offset = ($page - 1) * $perPage;
$paginatedData = array_slice($data, $offset, $perPage);
// Convert to arrays
$items = array_map(
fn($item) => method_exists($item, 'toArray') ? $item->toArray() : (array) $item,
$paginatedData
);
return new JsonResult([
'success' => true,
'data' => $items,
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'pages' => (int) ceil($total / $perPage),
],
]);
}
public function handleGet(
string $id,
mixed $repository
): JsonResult {
$item = $repository->findById($id);
if ($item === null) {
return new JsonResult([
'success' => false,
'error' => 'Resource not found',
], Status::NOT_FOUND);
}
$data = method_exists($item, 'toArray') ? $item->toArray() : (array) $item;
return new JsonResult([
'success' => true,
'data' => $data,
]);
}
public function handleCreate(
HttpRequest $request,
mixed $repository
): JsonResult {
$data = $request->parsedBody->toArray();
try {
$item = $repository->create($data);
$responseData = method_exists($item, 'toArray') ? $item->toArray() : (array) $item;
return new JsonResult([
'success' => true,
'data' => $responseData,
'message' => 'Resource created successfully',
], Status::CREATED);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::BAD_REQUEST);
}
}
public function handleUpdate(
string $id,
HttpRequest $request,
mixed $repository
): JsonResult {
$data = $request->parsedBody->toArray();
try {
$item = $repository->update($id, $data);
if ($item === null) {
return new JsonResult([
'success' => false,
'error' => 'Resource not found',
], Status::NOT_FOUND);
}
$responseData = method_exists($item, 'toArray') ? $item->toArray() : (array) $item;
return new JsonResult([
'success' => true,
'data' => $responseData,
'message' => 'Resource updated successfully',
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::BAD_REQUEST);
}
}
public function handleDelete(
string $id,
mixed $repository
): JsonResult {
try {
$deleted = $repository->delete($id);
if (!$deleted) {
return new JsonResult([
'success' => false,
'error' => 'Resource not found',
], Status::NOT_FOUND);
}
return new JsonResult([
'success' => true,
'message' => 'Resource deleted successfully',
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::BAD_REQUEST);
}
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
use App\Framework\View\FormBuilder;
use App\Framework\View\Table\Table;
/**
* Admin Page Renderer
*
* Provides consistent rendering for admin pages
*/
final readonly class AdminPageRenderer
{
public function __construct(
private AdminLayoutProcessor $layoutProcessor
) {
}
public function renderIndex(
string $resource,
Table $table,
string $title,
array $actions = []
): ViewResult {
$data = [
'title' => $title,
'resource' => $resource,
'table' => $table->render(),
'actions' => $actions,
];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult(
template: 'admin-index',
metaData: new MetaData($title, "Admin - {$title}"),
data: $finalData
);
}
public function renderForm(
string $resource,
FormBuilder $form,
string $title,
?string $subtitle = null
): ViewResult {
$data = [
'title' => $title,
'subtitle' => $subtitle,
'resource' => $resource,
'form' => $form->build(),
];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult(
template: 'admin-form',
metaData: new MetaData($title, "Admin - {$title}"),
data: $finalData
);
}
public function renderShow(
string $resource,
string $title,
array $data
): ViewResult {
$pageData = [
'title' => $title,
'resource' => $resource,
...$data,
];
$finalData = $this->layoutProcessor->processLayoutFromArray($pageData);
return new ViewResult(
template: 'admin-show',
metaData: new MetaData($title, "Admin - {$title}"),
data: $finalData
);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\Attributes;
/**
* Admin Resource Attribute
*
* Marks a controller as an admin resource with configuration
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
final readonly class AdminResource
{
public function __construct(
public string $name,
public string $singularName,
public string $pluralName,
public string $icon = 'file',
public bool $enableApi = true,
public bool $enableCrud = true,
public array $permissions = [],
) {
}
public function getApiEndpoint(): string
{
return "/admin/api/{$this->name}";
}
public function getIndexRoute(): string
{
return "/admin/{$this->name}";
}
public function getCreateRoute(): string
{
return "/admin/{$this->name}/create";
}
public function getEditRoute(string $id): string
{
return "/admin/{$this->name}/{$id}/edit";
}
public function getShowRoute(string $id): string
{
return "/admin/{$this->name}/{$id}";
}
}

View File

@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\Examples;
use App\Framework\Admin\Services\CrudService;
use App\Framework\Admin\ValueObjects\CrudConfig;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Responses\Redirect;
use App\Framework\Http\Responses\ViewResult;
/**
* Campaign CRUD Controller - Example Implementation
*
* Demonstrates composition-based CRUD pattern using CrudService
* No inheritance - pure composition following framework principles
*/
final readonly class CampaignCrudController
{
private CrudConfig $config;
public function __construct(
private CrudService $crudService,
private CampaignRepository $campaignRepository
) {
// Configure CRUD settings for campaigns resource
$this->config = CrudConfig::forResource(
resource: 'campaigns',
resourceName: 'Campaign',
title: 'Campaigns'
)->withColumns([
['field' => 'artist_name', 'label' => 'Artist', 'sortable' => true],
['field' => 'campaign_type', 'label' => 'Type', 'sortable' => true],
['field' => 'status', 'label' => 'Status', 'sortable' => true],
['field' => 'created_at', 'label' => 'Created', 'sortable' => true],
])->withFilters([
['field' => 'status', 'type' => 'select', 'options' => ['active', 'draft', 'archived']],
['field' => 'campaign_type', 'type' => 'select', 'options' => ['pre-save', 'release', 'tour']],
])->withBulkActions([
['name' => 'activate', 'label' => 'Activate Selected'],
['name' => 'archive', 'label' => 'Archive Selected'],
]);
}
/**
* Show campaigns index
*/
#[Route(path: '/admin/campaigns', method: Method::GET)]
public function index(Request $request): ViewResult
{
// Fetch campaigns from repository
$campaigns = $this->campaignRepository->findAll();
// Delegate rendering to CrudService
return $this->crudService->renderIndex(
config: $this->config,
items: $campaigns,
request: $request,
pagination: null // Add pagination if needed
);
}
/**
* Show create campaign form
*/
#[Route(path: '/admin/campaigns/create', method: Method::GET)]
public function create(): ViewResult
{
return $this->crudService->renderCreate(
config: $this->config,
formFields: $this->getCampaignFormFields(),
defaultData: null,
helpText: 'Create a new campaign for an artist. Pre-save campaigns allow fans to save music before release.'
);
}
/**
* Store new campaign
*/
#[Route(path: '/admin/campaigns/store', method: Method::POST)]
public function store(Request $request): Redirect
{
try {
// Validate and create campaign
$data = $request->parsedBody->toArray();
$campaign = $this->campaignRepository->create($data);
// Delegate redirect to CrudService
return $this->crudService->redirectAfterCreate(
config: $this->config,
request: $request,
itemId: $campaign->id
);
} catch (\Exception $e) {
return $this->crudService->redirectWithError(
message: 'Failed to create campaign: ' . $e->getMessage(),
inputData: $request->parsedBody->toArray()
);
}
}
/**
* Show edit campaign form
*/
#[Route(path: '/admin/campaigns/edit/{id}', method: Method::GET)]
public function edit(Request $request): ViewResult
{
$id = $request->routeParams->get('id');
$campaign = $this->campaignRepository->findById($id);
return $this->crudService->renderEdit(
config: $this->config,
id: $id,
formFields: $this->getCampaignFormFields(),
itemData: $campaign->toArray(),
metadata: [
'id' => $campaign->id,
'createdAt' => $campaign->created_at->format('Y-m-d H:i:s'),
'updatedAt' => $campaign->updated_at?->format('Y-m-d H:i:s'),
'status' => ucfirst($campaign->status),
'statusColor' => $this->getStatusColor($campaign->status),
],
helpText: 'Edit campaign details. Changes will be reflected immediately.'
);
}
/**
* Update campaign
*/
#[Route(path: '/admin/campaigns/update/{id}', method: Method::PUT)]
public function update(Request $request): Redirect
{
try {
$id = $request->routeParams->get('id');
$data = $request->parsedBody->toArray();
$this->campaignRepository->update($id, $data);
return $this->crudService->redirectAfterUpdate(
config: $this->config,
request: $request,
itemId: $id
);
} catch (\Exception $e) {
return $this->crudService->redirectWithError(
message: 'Failed to update campaign: ' . $e->getMessage(),
inputData: $request->parsedBody->toArray()
);
}
}
/**
* Show campaign details
*/
#[Route(path: '/admin/campaigns/view/{id}', method: Method::GET)]
public function show(Request $request): ViewResult
{
$id = $request->routeParams->get('id');
$campaign = $this->campaignRepository->findById($id);
return $this->crudService->renderShow(
config: $this->config,
id: $id,
fields: [
['label' => 'Artist Name', 'value' => $campaign->artist_name, 'type' => 'text'],
['label' => 'Campaign Type', 'value' => ucfirst($campaign->campaign_type), 'type' => 'badge', 'color' => 'primary'],
['label' => 'Status', 'value' => ucfirst($campaign->status), 'type' => 'badge', 'color' => $this->getStatusColor($campaign->status)],
['label' => 'Spotify URI', 'value' => $campaign->spotify_uri, 'type' => 'text'],
['label' => 'Release Date', 'value' => $campaign->release_date?->format('Y-m-d'), 'type' => 'text'],
['label' => 'Total Saves', 'value' => $campaign->stats['total_saves'] ?? 0, 'type' => 'text'],
],
metadata: [
'id' => $campaign->id,
'createdAt' => $campaign->created_at->format('Y-m-d H:i:s'),
'updatedAt' => $campaign->updated_at?->format('Y-m-d H:i:s'),
'status' => ucfirst($campaign->status),
'statusColor' => $this->getStatusColor($campaign->status),
],
relatedItems: null, // Add related items if needed
actions: [
['type' => 'link', 'url' => "/admin/campaigns/{$id}/analytics", 'label' => 'View Analytics', 'icon' => 'graph-up', 'variant' => 'primary'],
['type' => 'button', 'name' => 'duplicate', 'label' => 'Duplicate Campaign', 'icon' => 'files', 'variant' => 'secondary'],
]
);
}
/**
* Delete campaign
*/
#[Route(path: '/admin/campaigns/delete/{id}', method: Method::DELETE)]
public function destroy(Request $request): Redirect
{
try {
$id = $request->routeParams->get('id');
$this->campaignRepository->delete($id);
return $this->crudService->redirectAfterDelete($this->config);
} catch (\Exception $e) {
return $this->crudService->redirectWithError(
message: 'Failed to delete campaign: ' . $e->getMessage()
);
}
}
/**
* Get campaign form fields configuration
*/
private function getCampaignFormFields(): array
{
return [
[
'type' => 'text',
'name' => 'artist_name',
'label' => 'Artist Name',
'required' => true,
'placeholder' => 'Enter artist name',
],
[
'type' => 'select',
'name' => 'campaign_type',
'label' => 'Campaign Type',
'required' => true,
'options' => [
['value' => 'pre-save', 'label' => 'Pre-Save Campaign'],
['value' => 'release', 'label' => 'Release Campaign'],
['value' => 'tour', 'label' => 'Tour Campaign'],
],
],
[
'type' => 'text',
'name' => 'spotify_uri',
'label' => 'Spotify URI',
'required' => true,
'placeholder' => 'spotify:album:...',
'help' => 'Full Spotify URI for the album or track',
],
[
'type' => 'date',
'name' => 'release_date',
'label' => 'Release Date',
'required' => false,
],
[
'type' => 'select',
'name' => 'status',
'label' => 'Status',
'required' => true,
'options' => [
['value' => 'draft', 'label' => 'Draft'],
['value' => 'active', 'label' => 'Active'],
['value' => 'archived', 'label' => 'Archived'],
],
],
[
'type' => 'textarea',
'name' => 'description',
'label' => 'Description',
'required' => false,
'placeholder' => 'Campaign description (optional)',
'rows' => 4,
],
];
}
/**
* Get Bootstrap color for campaign status
*/
private function getStatusColor(string $status): string
{
return match ($status) {
'active' => 'success',
'draft' => 'warning',
'archived' => 'secondary',
default => 'info',
};
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\Examples;
/**
* Campaign Repository - Example Stub
*
* Stub implementation for the example CRUD controller
* In a real implementation, this would use the EntityManager
*/
final readonly class CampaignRepository
{
public function findAll(): array
{
// Stub: Return sample data
return [];
}
public function findById(string $id): object
{
// Stub: Return sample campaign object
return (object) [
'id' => $id,
'artist_name' => 'Example Artist',
'campaign_type' => 'pre-save',
'status' => 'active',
'spotify_uri' => 'spotify:album:example',
'release_date' => new \DateTime('2025-01-01'),
'created_at' => new \DateTime(),
'updated_at' => new \DateTime(),
'stats' => ['total_saves' => 0],
];
}
public function create(array $data): object
{
// Stub: Create campaign
return (object) ['id' => 'new-id'];
}
public function update(string $id, array $data): void
{
// Stub: Update campaign
}
public function delete(string $id): void
{
// Stub: Delete campaign
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\Factories;
use App\Framework\Admin\ValueObjects\AdminFormConfig;
use App\Framework\Admin\FormFields\FormFieldFactory;
use App\Framework\Http\Session\FormIdGenerator;
use App\Framework\View\FormBuilder;
/**
* Admin Form Factory
*
* Creates admin forms using composition-based field system
*/
final readonly class AdminFormFactory
{
public function __construct(
private FormIdGenerator $formIdGenerator,
private FormFieldFactory $fieldFactory
) {}
public function create(AdminFormConfig $config): FormBuilder
{
// Use existing FormBuilder
$form = FormBuilder::create(
action: $config->action,
method: $config->method->value,
formIdGenerator: $this->formIdGenerator
)->withClass('admin-form');
// Create fields using composition-based field factory
foreach ($config->fields as $name => $fieldConfig) {
// Merge field config with data value
$fieldConfigWithValue = [
...$fieldConfig,
'name' => $name,
'value' => $config->data[$name] ?? null
];
// Create field using factory
$field = $this->fieldFactory->createFromConfig($fieldConfigWithValue);
// Render field to form
$form = $field->render($form);
}
return $form->addSubmitButton('Save');
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\Factories;
use App\Framework\Admin\ValueObjects\AdminTableConfig;
use App\Framework\View\Table\Table;
use App\Framework\View\Table\TableOptions;
use App\Framework\View\Table\Formatters;
/**
* Admin Table Factory
*
* Creates admin tables using the existing Table module
*/
final readonly class AdminTableFactory
{
public function create(AdminTableConfig $config, array $data): Table
{
// Build column definitions from config
$columnDefs = [];
foreach ($config->columns as $key => $columnConfig) {
$columnDefs[$key] = [
'header' => $columnConfig['label'],
'sortable' => $columnConfig['sortable'] ?? $config->sortable,
'formatter' => $this->getFormatter($columnConfig['formatter'] ?? null),
'class' => $columnConfig['class'] ?? null,
];
}
// Build table options with data attributes for JavaScript
$options = TableOptions::admin()
->withId($config->resource . 'Table')
->withDataAttributes([
'resource' => $config->resource,
'api-endpoint' => $config->getApiEndpoint(),
'sortable' => $config->sortable ? 'true' : 'false',
'searchable' => $config->searchable ? 'true' : 'false',
'paginated' => $config->paginated ? 'true' : 'false',
'per-page' => (string) $config->perPage,
]);
// Use existing Table::fromArray
return Table::fromArray(
data: $data,
columnDefs: $columnDefs,
options: $options
);
}
private function getFormatter(?string $type): mixed
{
return match ($type) {
'date' => new Formatters\DateFormatter(),
'status' => Formatters\StatusFormatter::withBadges(),
'boolean' => Formatters\BooleanFormatter::germanWithBadges(),
'currency' => new Formatters\CurrencyFormatter(),
'number' => new Formatters\NumberFormatter(),
'masked' => new Formatters\MaskedFormatter(),
default => null,
};
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Components;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\View\ValueObjects\FormElement;
/**
* Field Wrapper Component
*
* Wraps field content with label and help text
*/
final readonly class FieldWrapper
{
/**
* Wrap field content with label and optional help text
*/
public function wrap(string $content, FieldMetadata $metadata): string
{
$label = FormElement::create('label', ['for' => $metadata->name], $metadata->label);
$html = $label . $content;
if ($metadata->help !== null) {
$help = FormElement::create('small', ['class' => 'form-text text-muted'], $metadata->help);
$html .= $help;
}
return FormElement::create('div', ['class' => 'form-group'], $html);
}
/**
* Wrap without label (for checkbox, radio, etc.)
*/
public function wrapWithoutLabel(string $content, FieldMetadata $metadata): string
{
$html = $content;
if ($metadata->help !== null) {
$help = FormElement::create('small', ['class' => 'form-text text-muted'], $metadata->help);
$html .= $help;
}
return FormElement::create('div', ['class' => 'form-group'], $html);
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
/**
* Checkbox Field
*
* Single checkbox input field using composition
*/
final readonly class CheckboxField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private mixed $value = null,
private string $checkedValue = '1'
) {}
public static function create(
string $name,
string $label,
mixed $value = null,
bool $required = false,
?string $help = null,
string $checkedValue = '1'
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
name: $name,
id: $name,
class: 'form-check-input',
required: $required
),
wrapper: new FieldWrapper(),
value: $value,
checkedValue: $checkedValue
);
}
public function render(FormBuilder $form): FormBuilder
{
$attrs = $this->attributes->withAdditional(['type' => 'checkbox', 'value' => $this->checkedValue]);
$attrArray = $attrs->toArray();
if ($this->isChecked()) {
$attrArray['checked'] = 'checked';
}
$input = FormElement::create('input', $attrArray);
$label = FormElement::create(
'label',
['class' => 'form-check-label', 'for' => $this->metadata->name],
$this->metadata->label
);
$checkboxHtml = FormElement::create(
'div',
['class' => 'form-check'],
$input . $label
);
$wrapped = $this->wrapper->wrapWithoutLabel($checkboxHtml, $this->metadata);
return $form->addElement($wrapped);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
private function isChecked(): bool
{
if ($this->value === null) {
return false;
}
if (is_bool($this->value)) {
return $this->value;
}
return (string) $this->value === $this->checkedValue;
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
use DateTimeInterface;
/**
* DateTime Input Field
*
* DateTime input field with formatting using composition
*/
final readonly class DateTimeField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private mixed $value = null,
private string $type = 'datetime-local'
) {}
public static function create(
string $name,
string $label,
mixed $value = null,
bool $required = false,
?string $help = null,
string $type = 'datetime-local'
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
name: $name,
id: $name,
required: $required
),
wrapper: new FieldWrapper(),
value: $value,
type: $type
);
}
public function render(FormBuilder $form): FormBuilder
{
$attrs = $this->attributes->withAdditional(['type' => $this->type]);
$attrArray = $attrs->toArray();
if ($this->value !== null) {
$attrArray['value'] = $this->formatValue($this->value);
}
$input = FormElement::create('input', $attrArray);
$wrapped = $this->wrapper->wrap($input, $this->metadata);
return $form->addElement($wrapped);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
private function formatValue(mixed $value): string
{
if ($value instanceof DateTimeInterface) {
return $value->format($this->getFormatForType());
}
if (is_string($value) && strtotime($value) !== false) {
return date($this->getFormatForType(), strtotime($value));
}
return (string) $value;
}
private function getFormatForType(): string
{
return match ($this->type) {
'date' => 'Y-m-d',
'time' => 'H:i',
'datetime-local' => 'Y-m-d\TH:i',
'month' => 'Y-m',
'week' => 'Y-\WW',
default => 'Y-m-d\TH:i'
};
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
/**
* Email Input Field
*
* Email input field with validation using composition
*/
final readonly class EmailField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private mixed $value = null
) {}
public static function create(
string $name,
string $label,
mixed $value = null,
bool $required = false,
?string $placeholder = null,
?string $help = null
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
name: $name,
id: $name,
required: $required,
placeholder: $placeholder
),
wrapper: new FieldWrapper(),
value: $value
);
}
public function render(FormBuilder $form): FormBuilder
{
$attrs = $this->attributes->withAdditional(['type' => 'email']);
$attrArray = $attrs->toArray();
if ($this->value !== null && $this->value !== '') {
$attrArray['value'] = (string) $this->value;
}
$input = FormElement::create('input', $attrArray);
$wrapped = $this->wrapper->wrap($input, $this->metadata);
return $form->addElement($wrapped);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
/**
* Hidden Input Field
*
* Hidden input field using composition (no wrapper needed)
*/
final readonly class HiddenField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private mixed $value = null
) {}
public static function create(
string $name,
mixed $value = null
): self {
return new self(
metadata: new FieldMetadata($name, ''),
attributes: new FieldAttributes(
name: $name,
id: $name,
class: ''
),
value: $value
);
}
public function render(FormBuilder $form): FormBuilder
{
$attrs = $this->attributes->withAdditional(['type' => 'hidden']);
$attrArray = $attrs->toArray();
// Remove empty class attribute for hidden fields
if (empty($attrArray['class'])) {
unset($attrArray['class']);
}
if ($this->value !== null && $this->value !== '') {
$attrArray['value'] = (string) $this->value;
}
$input = FormElement::create('input', $attrArray);
return $form->addElement($input);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
/**
* Number Input Field
*
* Number input field with min/max/step using composition
*/
final readonly class NumberField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private mixed $value = null,
private ?int $min = null,
private ?int $max = null,
private ?int $step = null
) {}
public static function create(
string $name,
string $label,
mixed $value = null,
bool $required = false,
?string $placeholder = null,
?string $help = null,
?int $min = null,
?int $max = null,
?int $step = null
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
name: $name,
id: $name,
required: $required,
placeholder: $placeholder
),
wrapper: new FieldWrapper(),
value: $value,
min: $min,
max: $max,
step: $step
);
}
public function render(FormBuilder $form): FormBuilder
{
$additionalAttrs = ['type' => 'number'];
if ($this->min !== null) {
$additionalAttrs['min'] = (string) $this->min;
}
if ($this->max !== null) {
$additionalAttrs['max'] = (string) $this->max;
}
if ($this->step !== null) {
$additionalAttrs['step'] = (string) $this->step;
}
$attrs = $this->attributes->withAdditional($additionalAttrs);
$attrArray = $attrs->toArray();
if ($this->value !== null && $this->value !== '') {
$attrArray['value'] = (string) $this->value;
}
$input = FormElement::create('input', $attrArray);
$wrapped = $this->wrapper->wrap($input, $this->metadata);
return $form->addElement($wrapped);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\ValueObjects\FieldOptions;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
/**
* Select Dropdown Field
*
* Select field with options using composition
*/
final readonly class SelectField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private FieldOptions $options,
private mixed $value = null
) {}
public static function create(
string $name,
string $label,
array $options,
mixed $value = null,
bool $required = false,
?string $help = null,
?string $placeholder = null
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
name: $name,
id: $name,
required: $required
),
wrapper: new FieldWrapper(),
options: new FieldOptions($options, $placeholder),
value: $value
);
}
public function render(FormBuilder $form): FormBuilder
{
$attrArray = $this->attributes->toArray();
$optionsHtml = $this->options->toHtml((string) $this->value);
$select = FormElement::create('select', $attrArray, $optionsHtml);
$wrapped = $this->wrapper->wrap($select, $this->metadata);
return $form->addElement($wrapped);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
/**
* Text Input Field
*
* Standard text input field using composition
*/
final readonly class TextField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private mixed $value = null
) {}
public static function create(
string $name,
string $label,
mixed $value = null,
bool $required = false,
?string $placeholder = null,
?string $help = null
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
name: $name,
id: $name,
required: $required,
placeholder: $placeholder
),
wrapper: new FieldWrapper(),
value: $value
);
}
public function render(FormBuilder $form): FormBuilder
{
$attrs = $this->attributes->withAdditional(['type' => 'text']);
$attrArray = $attrs->toArray();
if ($this->value !== null && $this->value !== '') {
$attrArray['value'] = (string) $this->value;
}
$input = FormElement::create('input', $attrArray);
$wrapped = $this->wrapper->wrap($input, $this->metadata);
return $form->addElement($wrapped);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\Fields;
use App\Framework\Admin\FormFields\FormField;
use App\Framework\Admin\FormFields\ValueObjects\FieldAttributes;
use App\Framework\Admin\FormFields\ValueObjects\FieldMetadata;
use App\Framework\Admin\FormFields\Components\FieldWrapper;
use App\Framework\View\FormBuilder;
use App\Framework\View\ValueObjects\FormElement;
/**
* Textarea Field
*
* Multi-line text input field using composition
*/
final readonly class TextareaField implements FormField
{
public function __construct(
private FieldMetadata $metadata,
private FieldAttributes $attributes,
private FieldWrapper $wrapper,
private mixed $value = null,
private int $rows = 5
) {}
public static function create(
string $name,
string $label,
mixed $value = null,
bool $required = false,
?string $placeholder = null,
?string $help = null,
int $rows = 5
): self {
return new self(
metadata: new FieldMetadata($name, $label, $help),
attributes: new FieldAttributes(
name: $name,
id: $name,
required: $required,
placeholder: $placeholder
),
wrapper: new FieldWrapper(),
value: $value,
rows: $rows
);
}
public function render(FormBuilder $form): FormBuilder
{
$attrs = $this->attributes->withAdditional(['rows' => (string) $this->rows]);
$attrArray = $attrs->toArray();
$content = $this->value !== null ? htmlspecialchars((string) $this->value) : '';
$textarea = FormElement::create('textarea', $attrArray, $content);
$wrapped = $this->wrapper->wrap($textarea, $this->metadata);
return $form->addElement($wrapped);
}
public function getName(): string
{
return $this->metadata->name;
}
public function getValue(): mixed
{
return $this->value;
}
public function getLabel(): string
{
return $this->metadata->label;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields;
use App\Framework\View\FormBuilder;
/**
* Form Field Interface
*
* All form fields must implement this interface
*/
interface FormField
{
/**
* Render the field and add it to the form builder
*/
public function render(FormBuilder $form): FormBuilder;
/**
* Get the field name
*/
public function getName(): string;
/**
* Get the field value
*/
public function getValue(): mixed;
/**
* Get the field label
*/
public function getLabel(): string;
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields;
use App\Framework\Admin\FormFields\Fields\TextField;
use App\Framework\Admin\FormFields\Fields\EmailField;
use App\Framework\Admin\FormFields\Fields\NumberField;
use App\Framework\Admin\FormFields\Fields\TextareaField;
use App\Framework\Admin\FormFields\Fields\SelectField;
use App\Framework\Admin\FormFields\Fields\DateTimeField;
use App\Framework\Admin\FormFields\Fields\CheckboxField;
use App\Framework\Admin\FormFields\Fields\HiddenField;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
/**
* Form Field Factory
*
* Creates form fields from configuration using composition-based field classes
*/
final readonly class FormFieldFactory
{
/**
* Create a field from configuration array
*
* @param array{
* type: string,
* name: string,
* label?: string,
* value?: mixed,
* required?: bool,
* placeholder?: string,
* help?: string,
* options?: array<string, string>,
* rows?: int,
* min?: int,
* max?: int,
* step?: int,
* checked_value?: string,
* datetime_type?: string
* } $config
*/
public function createFromConfig(array $config): FormField
{
$type = $config['type'] ?? 'text';
$name = $config['name'] ?? throw FrameworkException::create(
ErrorCode::VAL_INVALID_INPUT,
'Field name is required'
);
return match ($type) {
'text' => TextField::create(
name: $name,
label: $config['label'] ?? ucfirst($name),
value: $config['value'] ?? null,
required: $config['required'] ?? false,
placeholder: $config['placeholder'] ?? null,
help: $config['help'] ?? null
),
'email' => EmailField::create(
name: $name,
label: $config['label'] ?? ucfirst($name),
value: $config['value'] ?? null,
required: $config['required'] ?? false,
placeholder: $config['placeholder'] ?? null,
help: $config['help'] ?? null
),
'number' => NumberField::create(
name: $name,
label: $config['label'] ?? ucfirst($name),
value: $config['value'] ?? null,
required: $config['required'] ?? false,
placeholder: $config['placeholder'] ?? null,
help: $config['help'] ?? null,
min: $config['min'] ?? null,
max: $config['max'] ?? null,
step: $config['step'] ?? null
),
'textarea' => TextareaField::create(
name: $name,
label: $config['label'] ?? ucfirst($name),
value: $config['value'] ?? null,
required: $config['required'] ?? false,
placeholder: $config['placeholder'] ?? null,
help: $config['help'] ?? null,
rows: $config['rows'] ?? 5
),
'select' => SelectField::create(
name: $name,
label: $config['label'] ?? ucfirst($name),
options: $config['options'] ?? [],
value: $config['value'] ?? null,
required: $config['required'] ?? false,
help: $config['help'] ?? null,
placeholder: $config['placeholder'] ?? null
),
'datetime', 'date', 'time' => DateTimeField::create(
name: $name,
label: $config['label'] ?? ucfirst($name),
value: $config['value'] ?? null,
required: $config['required'] ?? false,
help: $config['help'] ?? null,
type: $config['datetime_type'] ?? $type
),
'checkbox' => CheckboxField::create(
name: $name,
label: $config['label'] ?? ucfirst($name),
value: $config['value'] ?? null,
required: $config['required'] ?? false,
help: $config['help'] ?? null,
checkedValue: $config['checked_value'] ?? '1'
),
'hidden' => HiddenField::create(
name: $name,
value: $config['value'] ?? null
),
default => throw FrameworkException::create(
ErrorCode::VAL_INVALID_INPUT,
"Unsupported field type: {$type}"
)
};
}
/**
* Create multiple fields from configuration array
*
* @param array<array{type: string, name: string}> $fieldsConfig
* @return FormField[]
*/
public function createMultiple(array $fieldsConfig): array
{
return array_map(
fn(array $config) => $this->createFromConfig($config),
$fieldsConfig
);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\ValueObjects;
/**
* Field Attributes Value Object
*
* Holds HTML attributes for form fields
*/
final readonly class FieldAttributes
{
/**
* @param array<string, string> $additional
*/
public function __construct(
public string $name,
public string $id,
public string $class = 'form-control',
public bool $required = false,
public ?string $placeholder = null,
public array $additional = []
) {}
/**
* Convert attributes to array for rendering
*
* @return array<string, string>
*/
public function toArray(): array
{
$attrs = [
'name' => $this->name,
'id' => $this->id,
'class' => $this->class,
...$this->additional
];
if ($this->required) {
$attrs['required'] = 'required';
}
if ($this->placeholder !== null) {
$attrs['placeholder'] = $this->placeholder;
}
return $attrs;
}
/**
* Create with additional attributes
*
* @param array<string, string> $attrs
*/
public function withAdditional(array $attrs): self
{
return new self(
name: $this->name,
id: $this->id,
class: $this->class,
required: $this->required,
placeholder: $this->placeholder,
additional: [...$this->additional, ...$attrs]
);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\ValueObjects;
/**
* Field Metadata Value Object
*
* Holds metadata for form fields (name, label, help text)
*/
final readonly class FieldMetadata
{
public function __construct(
public string $name,
public string $label,
public ?string $help = null
) {}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\FormFields\ValueObjects;
/**
* Field Options Value Object
*
* Holds options for select/radio/checkbox fields
*/
final readonly class FieldOptions
{
/**
* @param array<string, string> $options Key-value pairs for options
* @param string|null $placeholder Optional placeholder option
*/
public function __construct(
public array $options,
public ?string $placeholder = null
) {}
/**
* Convert options to HTML option elements
*/
public function toHtml(string $selectedValue = ''): string
{
$html = '';
if ($this->placeholder !== null) {
$html .= '<option value="">' . htmlspecialchars($this->placeholder) . '</option>';
}
foreach ($this->options as $value => $label) {
$selected = ((string) $value === $selectedValue) ? ' selected' : '';
$html .= sprintf(
'<option value="%s"%s>%s</option>',
htmlspecialchars((string) $value),
$selected,
htmlspecialchars($label)
);
}
return $html;
}
/**
* Get options as array
*
* @return array<string, string>
*/
public function toArray(): array
{
return $this->options;
}
}

View File

@@ -0,0 +1,691 @@
# Admin Framework
Streamlined, attribute-based admin panel framework for the Custom PHP Framework.
## Overview
The Admin Framework provides a unified, consistent approach to building admin interfaces with:
- **Attribute-Driven Configuration** - Use `#[AdminResource]` for controller-level metadata
- **Pure Composition** - No inheritance, all functionality via dependency injection
- **Factory Pattern** - AdminTableFactory, AdminFormFactory for building components
- **Interactive JavaScript** - Auto-initializing data tables with sorting, searching, pagination
- **Auto-Generated APIs** - CRUD endpoints with AdminApiHandler
- **Consistent Rendering** - AdminPageRenderer for unified page layouts
## Architecture
```
┌─────────────────────┐
│ AdminResource │ Controller Attribute
│ (Metadata) │
└──────────┬──────────┘
├──────────────────────┬──────────────────────┬─────────────────────┐
│ │ │ │
┌──────────▼──────────┐ ┌───────▼──────────┐ ┌───────▼──────────┐ ┌──────▼────────┐
│ AdminTableFactory │ │ AdminFormFactory │ │ AdminPageRenderer│ │ AdminApiHandler│
│ (Build Tables) │ │ (Build Forms) │ │ (Render Pages) │ │ (CRUD API) │
└──────────┬──────────┘ └───────┬──────────┘ └───────┬──────────┘ └──────┬────────┘
│ │ │ │
├──────────────────────┴──────────────────────┘ │
│ │
┌──────────▼──────────┐ ┌────────────────────┐ ┌─────────────────────────┐ │
│ Table Module │ │ FormBuilder Module │ │ Templates │ │
│ (View/Table) │ │ (View/FormBuilder) │ │ (admin-index/form) │ │
└─────────────────────┘ └────────────────────┘ └─────────────────────────┘ │
┌──────────────────────────────────────────────────────────────────────────┐ │
│ JavaScript AdminDataTable Module │ │
│ - Auto-initialization via data attributes │◄┘
│ - AJAX loading with sorting, searching, pagination │
└────────────────────────────────────────────────────────────────────────────┘
```
## Core Components
### 1. AdminResource Attribute
Controller-level attribute for admin resource configuration.
```php
#[AdminResource(
name: 'users', // Resource name (route prefix)
singularName: 'User', // Singular display name
pluralName: 'Users', // Plural display name
icon: 'user', // Icon identifier
enableApi: true, // Auto-generate CRUD API
enableCrud: true // Enable CRUD operations
)]
final readonly class UserAdminController
{
// ...
}
```
### 2. AdminTableConfig Value Object
Type-safe table configuration.
```php
$tableConfig = AdminTableConfig::create(
resource: 'users',
columns: [
'id' => [
'label' => 'ID',
'sortable' => true,
'class' => 'text-center',
],
'email' => [
'label' => 'Email',
'sortable' => true,
'searchable' => true,
],
'status' => [
'label' => 'Status',
'formatter' => 'status', // badge, date, boolean, etc.
],
],
sortable: true,
searchable: true
);
```
**Available Formatters:**
- `date` - DateFormatter for date/time values
- `status` - StatusFormatter with badges
- `boolean` - BooleanFormatter (German: Ja/Nein with badges)
- `currency` - CurrencyFormatter
- `number` - NumberFormatter
- `masked` - MaskedFormatter for sensitive data
### 3. AdminFormConfig Value Object
Type-safe form configuration.
```php
$formConfig = new AdminFormConfig(
resource: 'users',
action: '/admin/users',
method: Method::POST,
fields: [
'email' => [
'type' => 'email',
'label' => 'Email Address',
'required' => true,
'placeholder' => 'user@example.com',
],
'name' => [
'type' => 'text',
'label' => 'Full Name',
'required' => true,
],
'role' => [
'type' => 'select',
'label' => 'Role',
'options' => [
'user' => 'User',
'admin' => 'Administrator',
],
],
'active' => [
'type' => 'checkbox',
'label' => 'Active',
],
]
);
```
**Supported Field Types:**
- `text` - Text input
- `email` - Email input
- `password` - Password input
- `textarea` - Multi-line text
- `file` - File upload
- `hidden` - Hidden field
- `select` - Dropdown select
- `checkbox` - Checkbox input
### 4. AdminTableFactory
Creates interactive tables using the existing Table module.
```php
final readonly class ExampleController
{
public function __construct(
private AdminTableFactory $tableFactory,
) {}
public function index(): ViewResult
{
$tableConfig = AdminTableConfig::create(/* ... */);
$data = $this->repository->findAll();
$table = $this->tableFactory->create($tableConfig, $data);
// Table automatically includes data attributes for JavaScript:
// data-resource="users"
// data-api-endpoint="/admin/api/users"
// data-sortable="true"
// data-searchable="true"
}
}
```
### 5. AdminFormFactory
Creates forms using the existing FormBuilder module.
```php
final readonly class ExampleController
{
public function __construct(
private AdminFormFactory $formFactory,
) {}
public function create(): ViewResult
{
$formConfig = new AdminFormConfig(/* ... */);
$form = $this->formFactory->create($formConfig);
// For edit forms, provide existing data:
$formConfig = $formConfig->withData($user->toArray());
$form = $this->formFactory->create($formConfig);
}
}
```
### 6. AdminPageRenderer
Consistent rendering for admin pages.
```php
final readonly class ExampleController
{
public function __construct(
private AdminPageRenderer $pageRenderer,
) {}
// Index/list page
public function index(): ViewResult
{
return $this->pageRenderer->renderIndex(
resource: 'users',
table: $table,
title: 'Users',
actions: [
[
'url' => '/admin/users/create',
'label' => 'Create User',
'icon' => 'plus',
],
]
);
}
// Form page (create/edit)
public function create(): ViewResult
{
return $this->pageRenderer->renderForm(
resource: 'users',
form: $form,
title: 'Create User',
subtitle: 'Add a new user to the system'
);
}
// Detail page
public function show(): ViewResult
{
return $this->pageRenderer->renderShow(
resource: 'users',
title: 'User Details',
data: [
'user' => $user->toArray(),
'stats' => $userStats,
]
);
}
}
```
### 7. AdminApiHandler
Auto-generated CRUD API endpoints.
```php
final readonly class ExampleController
{
public function __construct(
private AdminApiHandler $apiHandler,
private UserRepository $repository,
) {}
#[Route('/admin/api/users', Method::GET)]
public function apiList(HttpRequest $request): JsonResult
{
// Handles: page, per_page, sort_by, sort_dir, search
return $this->apiHandler->handleList($request, $this->repository);
}
#[Route('/admin/api/users/{id}', Method::GET)]
public function apiGet(string $id): JsonResult
{
return $this->apiHandler->handleGet($id, $this->repository);
}
#[Route('/admin/api/users', Method::POST)]
public function apiCreate(HttpRequest $request): JsonResult
{
return $this->apiHandler->handleCreate($request, $this->repository);
}
#[Route('/admin/api/users/{id}', Method::PUT)]
public function apiUpdate(string $id, HttpRequest $request): JsonResult
{
return $this->apiHandler->handleUpdate($id, $request, $this->repository);
}
#[Route('/admin/api/users/{id}', Method::DELETE)]
public function apiDelete(string $id): JsonResult
{
return $this->apiHandler->handleDelete($id, $this->repository);
}
}
```
### 8. JavaScript AdminDataTable Module
Interactive data table with AJAX operations.
**Auto-Initialization:**
```javascript
// Automatically initializes on elements with both attributes:
// [data-resource][data-api-endpoint]
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-resource][data-api-endpoint]').forEach(table => {
new AdminDataTable(table).init();
});
});
```
**Features:**
- ✅ AJAX loading with loading states
- ✅ Column sorting (click headers)
- ✅ Search/filtering (debounced)
- ✅ Pagination
- ✅ Empty state handling
- ✅ Error handling
- ✅ HTML escaping for security
**Data Attributes:**
```html
<table
data-resource="users"
data-api-endpoint="/admin/api/users"
data-sortable="true"
data-searchable="true"
data-paginated="true"
data-per-page="25"
>
<!-- Table content -->
</table>
```
## Complete Example
See `src/Application/Admin/UserAdminController.php` for a complete working example.
### Step 1: Create Controller with AdminResource
```php
#[AdminResource(
name: 'users',
singularName: 'User',
pluralName: 'Users',
icon: 'user',
enableApi: true,
enableCrud: true
)]
final readonly class UserAdminController
{
public function __construct(
private AdminPageRenderer $pageRenderer,
private AdminTableFactory $tableFactory,
private AdminFormFactory $formFactory,
private AdminApiHandler $apiHandler,
private UserRepository $repository,
) {}
}
```
### Step 2: Implement Index Route
```php
#[Route('/admin/users', Method::GET)]
public function index(): ViewResult
{
$tableConfig = AdminTableConfig::create(
resource: 'users',
columns: [
'id' => ['label' => 'ID', 'sortable' => true],
'email' => ['label' => 'Email', 'sortable' => true, 'searchable' => true],
'name' => ['label' => 'Name', 'sortable' => true, 'searchable' => true],
'created_at' => ['label' => 'Created', 'formatter' => 'date'],
'status' => ['label' => 'Status', 'formatter' => 'status'],
]
);
$users = $this->repository->findAll();
$table = $this->tableFactory->create($tableConfig, array_map(
fn($user) => $user->toArray(),
$users
));
return $this->pageRenderer->renderIndex(
resource: 'users',
table: $table,
title: 'Users',
actions: [
['url' => '/admin/users/create', 'label' => 'Create User', 'icon' => 'plus'],
]
);
}
```
### Step 3: Implement Create/Store Routes
```php
#[Route('/admin/users/create', Method::GET)]
public function create(): ViewResult
{
$formConfig = new AdminFormConfig(
resource: 'users',
action: '/admin/users',
method: Method::POST,
fields: [
'email' => ['type' => 'email', 'label' => 'Email', 'required' => true],
'name' => ['type' => 'text', 'label' => 'Name', 'required' => true],
'password' => ['type' => 'password', 'label' => 'Password', 'required' => true],
]
);
$form = $this->formFactory->create($formConfig);
return $this->pageRenderer->renderForm(
resource: 'users',
form: $form,
title: 'Create User'
);
}
#[Route('/admin/users', Method::POST)]
public function store(HttpRequest $request): Redirect
{
$data = $request->parsedBody->toArray();
$this->repository->create([
'email' => $data['email'],
'name' => $data['name'],
'password' => password_hash($data['password'], PASSWORD_DEFAULT),
]);
return new Redirect('/admin/users', status: 303, flashMessage: 'User created');
}
```
### Step 4: Implement Edit/Update Routes
```php
#[Route('/admin/users/{id}/edit', Method::GET)]
public function edit(string $id): ViewResult
{
$user = $this->repository->findById($id);
$formConfig = (new AdminFormConfig(
resource: 'users',
action: "/admin/users/{$id}",
method: Method::PUT,
fields: [
'email' => ['type' => 'email', 'label' => 'Email', 'required' => true],
'name' => ['type' => 'text', 'label' => 'Name', 'required' => true],
]
))->withData($user->toArray());
$form = $this->formFactory->create($formConfig);
return $this->pageRenderer->renderForm(
resource: 'users',
form: $form,
title: 'Edit User'
);
}
#[Route('/admin/users/{id}', Method::PUT)]
public function update(string $id, HttpRequest $request): Redirect
{
$data = $request->parsedBody->toArray();
$this->repository->update($id, [
'email' => $data['email'],
'name' => $data['name'],
]);
return new Redirect('/admin/users', status: 303, flashMessage: 'User updated');
}
```
### Step 5: Implement Delete Route
```php
#[Route('/admin/users/{id}', Method::DELETE)]
public function destroy(string $id): Redirect
{
$this->repository->delete($id);
return new Redirect('/admin/users', status: 303, flashMessage: 'User deleted');
}
```
### Step 6: Implement API Routes
```php
#[Route('/admin/api/users', Method::GET)]
public function apiList(HttpRequest $request): JsonResult
{
return $this->apiHandler->handleList($request, $this->repository);
}
#[Route('/admin/api/users/{id}', Method::GET)]
public function apiGet(string $id): JsonResult
{
return $this->apiHandler->handleGet($id, $this->repository);
}
#[Route('/admin/api/users', Method::POST)]
public function apiCreate(HttpRequest $request): JsonResult
{
return $this->apiHandler->handleCreate($request, $this->repository);
}
#[Route('/admin/api/users/{id}', Method::PUT)]
public function apiUpdate(string $id, HttpRequest $request): JsonResult
{
return $this->apiHandler->handleUpdate($id, $request, $this->repository);
}
#[Route('/admin/api/users/{id}', Method::DELETE)]
public function apiDelete(string $id): JsonResult
{
return $this->apiHandler->handleDelete($id, $this->repository);
}
```
## Templates
### admin-index.view.php
Used by `AdminPageRenderer::renderIndex()`.
```php
<layout name="layouts/admin" />
<div class="admin-page">
<div class="page-header">
<h1>{{ title }}</h1>
<div class="page-actions">
<if condition="{{ actions }}">
<for items="{{ actions }}" value="action">
<a href="{{ action.url }}" class="btn btn-primary">
<if condition="{{ action.icon }}">
<i class="icon-{{ action.icon }}"></i>
</if>
{{ action.label }}
</a>
</for>
</if>
</div>
</div>
<if condition="{{ searchable }}">
<div class="table-controls">
<input type="text"
class="form-control search-input"
data-table-search="{{ resource }}"
placeholder="Search...">
</div>
</if>
<div class="table-container">
{{ table }}
</div>
<div class="pagination-container" data-table-pagination="{{ resource }}"></div>
</div>
```
### admin-form.view.php
Used by `AdminPageRenderer::renderForm()`.
```php
<layout name="layouts/admin" />
<div class="admin-page">
<div class="page-header">
<h1>{{ title }}</h1>
<if condition="{{ subtitle }}">
<p class="subtitle">{{ subtitle }}</p>
</if>
</div>
<div class="form-container">
<div class="card">
<div class="card-body">
{{ form }}
</div>
</div>
</div>
<div class="form-actions">
<a href="/admin/{{ resource }}" class="btn btn-secondary">Cancel</a>
</div>
</div>
```
## Framework Compliance
The Admin Framework follows all framework principles:
**No Inheritance** - Pure composition, all controllers are `final readonly`
**Readonly Everywhere** - All framework classes are `readonly`
**Value Objects** - AdminTableConfig, AdminFormConfig instead of arrays
**Explicit DI** - All dependencies via constructor injection
**Attribute-Driven** - `#[AdminResource]` for metadata
**Existing Module Integration** - Reuses Table and FormBuilder modules
**Type Safety** - Strong typing throughout, no primitive obsession
**Immutability** - Value objects with transformation methods
## Best Practices
1. **Use Value Objects for Configuration** - Always use AdminTableConfig and AdminFormConfig instead of raw arrays
2. **Leverage Formatters** - Use built-in formatters for dates, status badges, booleans, etc.
3. **Provide Rich Context** - Include helpful labels, placeholders, and help text in forms
4. **Enable JavaScript Features** - Use sortable, searchable, paginated for better UX
5. **Implement All API Endpoints** - Required for JavaScript table functionality
6. **Use Consistent Naming** - Resource name should match routes and repository
7. **Inject Dependencies** - Always inject factories, renderers, handlers via constructor
8. **Type-Safe Data** - Convert entities to arrays with `toArray()` method
## Testing
```php
// Test table creation
it('creates admin table with correct configuration', function () {
$config = AdminTableConfig::create(
resource: 'test',
columns: ['id' => ['label' => 'ID']]
);
$factory = new AdminTableFactory();
$table = $factory->create($config, [['id' => 1]]);
expect($table)->toBeInstanceOf(Table::class);
expect($table->render())->toContain('data-resource="test"');
});
// Test form creation
it('creates admin form with correct fields', function () {
$config = new AdminFormConfig(
resource: 'test',
action: '/test',
method: Method::POST,
fields: [
'name' => ['type' => 'text', 'label' => 'Name']
]
);
$factory = new AdminFormFactory(new FormIdGenerator());
$form = $factory->create($config);
expect($form)->toBeInstanceOf(FormBuilder::class);
expect($form->build())->toContain('name="name"');
});
// Test API handler
it('handles list API requests', function () {
$handler = new AdminApiHandler();
$repository = new InMemoryUserRepository();
$request = HttpRequest::fromGlobals();
$result = $handler->handleList($request, $repository);
expect($result)->toBeInstanceOf(JsonResult::class);
expect($result->data['success'])->toBeTrue();
expect($result->data)->toHaveKey('pagination');
});
```
## Troubleshooting
**Tables not loading data:**
- Check API endpoint is correct: `/admin/api/{resource}`
- Verify API route is registered with correct HTTP method
- Ensure repository implements `findAll()` method
- Check browser console for JavaScript errors
**Forms not submitting:**
- Verify form action URL matches route
- Check HTTP method matches (POST for create, PUT for update)
- Ensure CSRF token is included (handled by FormBuilder automatically)
**Search not working:**
- Enable `searchable: true` in AdminTableConfig
- Mark specific columns as searchable: `'searchable' => true`
- Implement search logic in repository `findAll($filters)` method
**Sorting not working:**
- Enable `sortable: true` in AdminTableConfig
- Mark specific columns as sortable: `'sortable' => true`
- Implement sorting in repository or API handler

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\Services;
use App\Framework\Admin\Factories\AdminFormFactory;
use App\Framework\Admin\ValueObjects\AdminFormConfig;
use App\Framework\Admin\ValueObjects\CrudConfig;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Responses\Redirect;
use App\Framework\Http\Responses\ViewResult;
use App\Framework\View\TemplateRenderer;
/**
* CRUD Service
*
* Service for handling admin CRUD operations using composition
*/
final readonly class CrudService
{
public function __construct(
private TemplateRenderer $renderer,
private AdminFormFactory $formFactory
) {}
/**
* Render index view
*/
public function renderIndex(
CrudConfig $config,
array $items,
Request $request,
?array $pagination = null
): ViewResult {
$data = [
...$config->toArray(),
'items' => $items,
'pagination' => $pagination,
'currentUrl' => $request->uri(),
'createUrl' => "/admin/{$config->resource}/create",
'editUrl' => "/admin/{$config->resource}/edit",
'viewUrl' => "/admin/{$config->resource}/view",
'deleteUrl' => "/admin/{$config->resource}/delete",
];
return new ViewResult('crud-index', $data);
}
/**
* Render create form
*/
public function renderCreate(
CrudConfig $config,
array $formFields,
?array $defaultData = null,
?string $helpText = null
): ViewResult {
$formConfig = new AdminFormConfig(
resource: $config->resource,
action: "/admin/{$config->resource}/store",
method: Method::POST,
fields: $formFields,
data: $defaultData ?? []
);
$form = $this->formFactory->create($formConfig);
$data = [
'title' => "Create {$config->resourceName}",
'subtitle' => "Add a new {$config->resourceName} to the system",
'formTitle' => "{$config->resourceName} Details",
'resourceName' => $config->resourceName,
'form' => $form->build(),
'formId' => $form->getId(),
'backUrl' => "/admin/{$config->resource}",
'saveAndContinue' => true,
'helpText' => $helpText,
];
return new ViewResult('crud-create', $data);
}
/**
* Render edit form
*/
public function renderEdit(
CrudConfig $config,
string $id,
array $formFields,
array $itemData,
?array $metadata = null,
?string $helpText = null
): ViewResult {
$formConfig = new AdminFormConfig(
resource: $config->resource,
action: "/admin/{$config->resource}/update/{$id}",
method: Method::PUT,
fields: $formFields,
data: $itemData
);
$form = $this->formFactory->create($formConfig);
$data = [
'title' => "Edit {$config->resourceName}",
'subtitle' => "Update {$config->resourceName} information",
'formTitle' => "{$config->resourceName} Details",
'resourceName' => $config->resourceName,
'form' => $form->build(),
'formId' => $form->getId(),
'backUrl' => "/admin/{$config->resource}",
'viewUrl' => "/admin/{$config->resource}/view/{$id}",
'deleteUrl' => "/admin/{$config->resource}/delete/{$id}",
'canView' => $config->canView,
'canDelete' => $config->canDelete,
'saveAndView' => true,
'metadata' => $metadata,
'helpText' => $helpText,
];
return new ViewResult('crud-edit', $data);
}
/**
* Render show/detail view
*/
public function renderShow(
CrudConfig $config,
string $id,
array $fields,
?array $metadata = null,
?array $relatedItems = null,
?array $actions = null
): ViewResult {
$data = [
'title' => "{$config->resourceName} Details",
'subtitle' => "View {$config->resourceName} information",
'detailsTitle' => "{$config->resourceName} Information",
'resourceName' => $config->resourceName,
'fields' => $fields,
'backUrl' => "/admin/{$config->resource}",
'editUrl' => "/admin/{$config->resource}/edit/{$id}",
'deleteUrl' => "/admin/{$config->resource}/delete/{$id}",
'canEdit' => $config->canEdit,
'canDelete' => $config->canDelete,
'metadata' => $metadata,
'relatedItems' => $relatedItems,
'actions' => $actions,
];
return new ViewResult('crud-show', $data);
}
/**
* Create redirect for successful create
*/
public function redirectAfterCreate(
CrudConfig $config,
Request $request,
mixed $itemId
): Redirect {
// Check if "save and continue" was requested
if ($request->parsedBody->get('action') === 'save-and-continue') {
return Redirect::to("/admin/{$config->resource}/create")
->withFlash('success', "{$config->resourceName} created successfully!");
}
return Redirect::to("/admin/{$config->resource}")
->withFlash('success', "{$config->resourceName} created successfully!");
}
/**
* Create redirect for successful update
*/
public function redirectAfterUpdate(
CrudConfig $config,
Request $request,
string $itemId
): Redirect {
// Check if "save and view" was requested
if ($request->parsedBody->get('action') === 'save-and-view') {
return Redirect::to("/admin/{$config->resource}/view/{$itemId}")
->withFlash('success', "{$config->resourceName} updated successfully!");
}
return Redirect::to("/admin/{$config->resource}")
->withFlash('success', "{$config->resourceName} updated successfully!");
}
/**
* Create redirect for successful delete
*/
public function redirectAfterDelete(CrudConfig $config): Redirect
{
return Redirect::to("/admin/{$config->resource}")
->withFlash('success', "{$config->resourceName} deleted successfully!");
}
/**
* Create redirect for errors
*/
public function redirectWithError(string $message, ?array $inputData = null): Redirect
{
$redirect = Redirect::back()->withFlash('error', $message);
if ($inputData !== null) {
$redirect = $redirect->withInput($inputData);
}
return $redirect;
}
}

View File

@@ -0,0 +1,64 @@
<layout name="layouts/admin" />
<div class="admin-page">
<div class="page-header">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1>{{ title }}</h1>
<if condition="{{ subtitle }}">
<p class="text-muted">{{ subtitle }}</p>
</if>
</div>
<div>
<a href="{{ backUrl }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to List
</a>
</div>
</div>
</div>
<div class="form-container">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ formTitle }}</h5>
</div>
<div class="card-body">
<if condition="{{ errors }}">
<div class="alert alert-danger">
<h6>Please fix the following errors:</h6>
<ul class="mb-0">
<for var="error" in="errors">
<li>{{ error }}</li>
</for>
</ul>
</div>
</if>
{{ form }}
<div class="form-actions mt-4 d-flex gap-2">
<button type="submit" form="{{ formId }}" class="btn btn-primary">
<i class="bi bi-save"></i> Create {{ resourceName }}
</button>
<if condition="{{ saveAndContinue }}">
<button type="submit" form="{{ formId }}" name="action" value="save-and-continue" class="btn btn-secondary">
<i class="bi bi-plus-circle"></i> Save & Create Another
</button>
</if>
<a href="{{ backUrl }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</div>
</div>
<if condition="{{ helpText }}">
<div class="card mt-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-info-circle"></i> Help</h6>
</div>
<div class="card-body">
<p class="mb-0">{{ helpText }}</p>
</div>
</div>
</if>
</div>
</div>

View File

@@ -0,0 +1,150 @@
<layout name="layouts/admin" />
<div class="admin-page">
<div class="page-header">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1>{{ title }}</h1>
<if condition="{{ subtitle }}">
<p class="text-muted">{{ subtitle }}</p>
</if>
</div>
<div class="btn-group">
<if condition="{{ canView }}">
<a href="{{ viewUrl }}" class="btn btn-info">
<i class="bi bi-eye"></i> View
</a>
</if>
<a href="{{ backUrl }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to List
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{{ formTitle }}</h5>
<if condition="{{ lastModified }}">
<small class="text-muted">Last modified: {{ lastModified }}</small>
</if>
</div>
<div class="card-body">
<if condition="{{ errors }}">
<div class="alert alert-danger">
<h6>Please fix the following errors:</h6>
<ul class="mb-0">
<for var="error" in="errors">
<li>{{ error }}</li>
</for>
</ul>
</div>
</if>
<if condition="{{ successMessage }}">
<div class="alert alert-success">
{{ successMessage }}
</div>
</if>
{{ form }}
<div class="form-actions mt-4 d-flex gap-2">
<button type="submit" form="{{ formId }}" class="btn btn-primary">
<i class="bi bi-save"></i> Save Changes
</button>
<if condition="{{ saveAndView }}">
<button type="submit" form="{{ formId }}" name="action" value="save-and-view" class="btn btn-secondary">
Save & View
</button>
</if>
<a href="{{ backUrl }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<if condition="{{ metadata }}">
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-info-circle"></i> Information</h6>
</div>
<div class="card-body">
<dl class="row mb-0">
<if condition="{{ metadata.createdAt }}">
<dt class="col-sm-5">Created:</dt>
<dd class="col-sm-7">{{ metadata.createdAt }}</dd>
</if>
<if condition="{{ metadata.updatedAt }}">
<dt class="col-sm-5">Updated:</dt>
<dd class="col-sm-7">{{ metadata.updatedAt }}</dd>
</if>
<if condition="{{ metadata.createdBy }}">
<dt class="col-sm-5">Created by:</dt>
<dd class="col-sm-7">{{ metadata.createdBy }}</dd>
</if>
<if condition="{{ metadata.status }}">
<dt class="col-sm-5">Status:</dt>
<dd class="col-sm-7">
<span class="badge bg-{{ metadata.statusColor }}">{{ metadata.status }}</span>
</dd>
</if>
</dl>
</div>
</div>
</if>
<if condition="{{ canDelete }}">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h6 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Danger Zone</h6>
</div>
<div class="card-body">
<p class="mb-2">Permanently delete this {{ resourceName }}.</p>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteModal">
<i class="bi bi-trash"></i> Delete {{ resourceName }}
</button>
</div>
</div>
</if>
<if condition="{{ helpText }}">
<div class="card mt-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-question-circle"></i> Help</h6>
</div>
<div class="card-body">
<p class="mb-0">{{ helpText }}</p>
</div>
</div>
</if>
</div>
</div>
</div>
<if condition="{{ canDelete }}">
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirm Deletion</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this {{ resourceName }}?</p>
<p class="text-danger mb-0"><strong>This action cannot be undone.</strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" action="{{ deleteUrl }}" class="d-inline">
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
</div>
</div>
</div>
</if>

View File

@@ -0,0 +1,165 @@
<layout name="layouts/admin" />
<div class="admin-page">
<div class="page-header">
<h1>{{ title }}</h1>
<if condition="{{ subtitle }}">
<p class="text-muted">{{ subtitle }}</p>
</if>
<div class="page-actions">
<if condition="{{ canCreate }}">
<a href="{{ createUrl }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create New
</a>
</if>
</div>
</div>
<if condition="{{ filters }}">
<div class="filters-section card mb-3">
<div class="card-body">
<form method="GET" class="row g-3">
<for var="filter" in="filters">
<div class="col-md-3">
<label class="form-label">{{ filter.label }}</label>
<if condition="{{ filter.type == 'select' }}">
<select name="{{ filter.name }}" class="form-select">
<option value="">All</option>
<for var="option" in="filter.options">
<option value="{{ option.value }}">{{ option.label }}</option>
</for>
</select>
</if>
<if condition="{{ filter.type == 'text' }}">
<input type="text" name="{{ filter.name }}" class="form-control" placeholder="{{ filter.placeholder }}">
</if>
</div>
</for>
<div class="col-md-12">
<button type="submit" class="btn btn-secondary">Filter</button>
<a href="{{ currentUrl }}" class="btn btn-link">Clear</a>
</div>
</form>
</div>
</div>
</if>
<div class="table-container card">
<div class="card-body">
<if condition="{{ items }}">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<if condition="{{ bulkActions }}">
<th width="50">
<input type="checkbox" class="form-check-input" id="select-all">
</th>
</if>
<for var="column" in="columns">
<th>{{ column.label }}</th>
</for>
<if condition="{{ hasActions }}">
<th width="150">Actions</th>
</if>
</tr>
</thead>
<tbody>
<for var="item" in="items">
<tr>
<if condition="{{ bulkActions }}">
<td>
<input type="checkbox" class="form-check-input item-checkbox" value="{{ item.id }}">
</td>
</if>
<for var="column" in="columns">
<td>{{ item[column.field] }}</td>
</for>
<if condition="{{ hasActions }}">
<td>
<div class="btn-group btn-group-sm">
<if condition="{{ canView }}">
<a href="{{ viewUrl }}/{{ item.id }}" class="btn btn-info" title="View">
<i class="bi bi-eye"></i>
</a>
</if>
<if condition="{{ canEdit }}">
<a href="{{ editUrl }}/{{ item.id }}" class="btn btn-warning" title="Edit">
<i class="bi bi-pencil"></i>
</a>
</if>
<if condition="{{ canDelete }}">
<button type="button" class="btn btn-danger"
data-delete-item="{{ item.id }}"
title="Delete">
<i class="bi bi-trash"></i>
</button>
</if>
</div>
</td>
</if>
</tr>
</for>
</tbody>
</table>
</div>
<if condition="{{ pagination }}">
<div class="pagination-wrapper">
<nav>
<ul class="pagination">
<if condition="{{ pagination.hasPrevious }}">
<li class="page-item">
<a class="page-link" href="{{ pagination.previousUrl }}">Previous</a>
</li>
</if>
<for var="page" in="pagination.pages">
<li class="page-item {{ page.active ? 'active' : '' }}">
<a class="page-link" href="{{ page.url }}">{{ page.number }}</a>
</li>
</for>
<if condition="{{ pagination.hasNext }}">
<li class="page-item">
<a class="page-link" href="{{ pagination.nextUrl }}">Next</a>
</li>
</if>
</ul>
</nav>
<div class="pagination-info text-muted">
Showing {{ pagination.from }} to {{ pagination.to }} of {{ pagination.total }} entries
</div>
</div>
</if>
</if>
<if condition="{{ !items }}">
<div class="empty-state text-center py-5">
<i class="bi bi-inbox" style="font-size: 3rem; color: #ccc;"></i>
<p class="text-muted mt-3">No items found.</p>
<if condition="{{ canCreate }}">
<a href="{{ createUrl }}" class="btn btn-primary mt-2">Create First Item</a>
</if>
</div>
</if>
</div>
</div>
<if condition="{{ bulkActions }}">
<div class="bulk-actions-bar" style="display: none;">
<div class="selected-count">
<span id="selected-count">0</span> items selected
</div>
<div class="bulk-action-buttons">
<for var="action" in="bulkActions">
<button type="button" class="btn btn-{{ action.variant }}" data-bulk-action="{{ action.name }}">
{{ action.label }}
</button>
</for>
</div>
</div>
</if>
</div>

View File

@@ -0,0 +1,231 @@
<layout name="layouts/admin" />
<div class="admin-page">
<div class="page-header">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1>{{ title }}</h1>
<if condition="{{ subtitle }}">
<p class="text-muted">{{ subtitle }}</p>
</if>
</div>
<div class="btn-group">
<if condition="{{ canEdit }}">
<a href="{{ editUrl }}" class="btn btn-warning">
<i class="bi bi-pencil"></i> Edit
</a>
</if>
<a href="{{ backUrl }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to List
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ detailsTitle }}</h5>
</div>
<div class="card-body">
<if condition="{{ fields }}">
<for var="field" in="fields">
<div class="row mb-3">
<div class="col-md-4">
<strong>{{ field.label }}:</strong>
</div>
<div class="col-md-8">
<if condition="{{ field.type == 'html' }}">
<div>{{{ field.value }}}</div>
</if>
<if condition="{{ field.type == 'badge' }}">
<span class="badge bg-{{ field.color }}">{{ field.value }}</span>
</if>
<if condition="{{ field.type == 'link' }}">
<a href="{{ field.url }}">{{ field.value }}</a>
</if>
<if condition="{{ field.type == 'image' }}">
<img src="{{ field.value }}" alt="{{ field.label }}" class="img-thumbnail" style="max-width: 200px;">
</if>
<if condition="{{ field.type == 'boolean' }}">
<i class="bi bi-{{ field.value ? 'check-circle text-success' : 'x-circle text-danger' }}"></i>
{{ field.value ? 'Yes' : 'No' }}
</if>
<if condition="{{ field.type == 'text' || !field.type }}">
{{ field.value || '-' }}
</if>
</div>
</div>
</for>
</if>
</div>
</div>
<if condition="{{ relatedItems }}">
<for var="relation" in="relatedItems">
<div class="card mt-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">{{ relation.title }}</h6>
<if condition="{{ relation.createUrl }}">
<a href="{{ relation.createUrl }}" class="btn btn-sm btn-primary">
<i class="bi bi-plus"></i> Add
</a>
</if>
</div>
<div class="card-body">
<if condition="{{ relation.items }}">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<for var="column" in="relation.columns">
<th>{{ column.label }}</th>
</for>
<if condition="{{ relation.hasActions }}">
<th width="100">Actions</th>
</if>
</tr>
</thead>
<tbody>
<for var="item" in="relation.items">
<tr>
<for var="column" in="relation.columns">
<td>{{ item[column.field] }}</td>
</for>
<if condition="{{ relation.hasActions }}">
<td>
<div class="btn-group btn-group-sm">
<if condition="{{ relation.canEdit }}">
<a href="{{ relation.editUrl }}/{{ item.id }}" class="btn btn-outline-warning btn-sm">
<i class="bi bi-pencil"></i>
</a>
</if>
<if condition="{{ relation.canDelete }}">
<button type="button" class="btn btn-outline-danger btn-sm" data-delete-related="{{ item.id }}">
<i class="bi bi-trash"></i>
</button>
</if>
</div>
</td>
</if>
</tr>
</for>
</tbody>
</table>
</div>
</if>
<if condition="{{ !relation.items }}">
<p class="text-muted text-center mb-0">No {{ relation.title }} found.</p>
</if>
</div>
</div>
</for>
</if>
</div>
<div class="col-lg-4">
<if condition="{{ metadata }}">
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-info-circle"></i> Metadata</h6>
</div>
<div class="card-body">
<dl class="row mb-0">
<if condition="{{ metadata.id }}">
<dt class="col-sm-5">ID:</dt>
<dd class="col-sm-7"><code>{{ metadata.id }}</code></dd>
</if>
<if condition="{{ metadata.createdAt }}">
<dt class="col-sm-5">Created:</dt>
<dd class="col-sm-7">{{ metadata.createdAt }}</dd>
</if>
<if condition="{{ metadata.updatedAt }}">
<dt class="col-sm-5">Updated:</dt>
<dd class="col-sm-7">{{ metadata.updatedAt }}</dd>
</if>
<if condition="{{ metadata.createdBy }}">
<dt class="col-sm-5">Created by:</dt>
<dd class="col-sm-7">{{ metadata.createdBy }}</dd>
</if>
<if condition="{{ metadata.status }}">
<dt class="col-sm-5">Status:</dt>
<dd class="col-sm-7">
<span class="badge bg-{{ metadata.statusColor }}">{{ metadata.status }}</span>
</dd>
</if>
</dl>
</div>
</div>
</if>
<if condition="{{ actions }}">
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-lightning"></i> Quick Actions</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<for var="action" in="actions">
<if condition="{{ action.type == 'link' }}">
<a href="{{ action.url }}" class="btn btn-{{ action.variant || 'secondary' }}">
<if condition="{{ action.icon }}">
<i class="bi bi-{{ action.icon }}"></i>
</if>
{{ action.label }}
</a>
</if>
<if condition="{{ action.type == 'button' }}">
<button type="button" class="btn btn-{{ action.variant || 'secondary' }}" data-action="{{ action.name }}">
<if condition="{{ action.icon }}">
<i class="bi bi-{{ action.icon }}"></i>
</if>
{{ action.label }}
</button>
</if>
</for>
</div>
</div>
</div>
</if>
<if condition="{{ canDelete }}">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h6 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Danger Zone</h6>
</div>
<div class="card-body">
<p class="mb-2">Permanently delete this {{ resourceName }}.</p>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteModal">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</div>
</if>
</div>
</div>
</div>
<if condition="{{ canDelete }}">
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirm Deletion</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this {{ resourceName }}?</p>
<p class="text-danger mb-0"><strong>This action cannot be undone.</strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" action="{{ deleteUrl }}" class="d-inline">
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
</div>
</div>
</div>
</if>

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\ValueObjects;
use App\Framework\Http\Method;
/**
* Admin Form Configuration
*
* Configuration for creating admin forms
*/
final readonly class AdminFormConfig
{
/**
* @param array<string, array{type: string, label: string, required?: bool, options?: array, placeholder?: string, help?: string}> $fields
* @param array<string, mixed> $data
*/
public function __construct(
public string $resource,
public string $action,
public Method $method,
public array $fields,
public array $data = [],
) {
}
public static function create(
string $resource,
string $action,
Method $method,
array $fields
): self {
return new self(
resource: $resource,
action: $action,
method: $method,
fields: $fields
);
}
public function withData(array $data): self
{
return new self(
resource: $this->resource,
action: $this->action,
method: $this->method,
fields: $this->fields,
data: [...$this->data, ...$data]
);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\ValueObjects;
/**
* Admin Table Configuration
*
* Configuration for creating admin data tables
*/
final readonly class AdminTableConfig
{
/**
* @param array<string, array{label: string, sortable?: bool, searchable?: bool, formatter?: string, class?: string}> $columns
*/
public function __construct(
public array $columns,
public string $resource,
public bool $sortable = true,
public bool $searchable = true,
public bool $paginated = true,
public int $perPage = 25,
public ?string $apiEndpoint = null,
) {
}
public static function create(
string $resource,
array $columns,
bool $sortable = true,
bool $searchable = true
): self {
return new self(
columns: $columns,
resource: $resource,
sortable: $sortable,
searchable: $searchable,
apiEndpoint: "/admin/api/{$resource}"
);
}
public function getApiEndpoint(): string
{
return $this->apiEndpoint ?? "/admin/api/{$this->resource}";
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Framework\Admin\ValueObjects;
/**
* CRUD Configuration Value Object
*
* Configuration for admin CRUD operations
*/
final readonly class CrudConfig
{
/**
* @param string $resource Resource name (e.g., 'campaigns', 'users')
* @param string $resourceName Singular display name (e.g., 'Campaign', 'User')
* @param string $title Page title
* @param array<string, mixed> $columns Column definitions for index view
* @param bool $canCreate Can create new items
* @param bool $canEdit Can edit items
* @param bool $canView Can view items
* @param bool $canDelete Can delete items
* @param array<string, mixed>|null $filters Filter definitions
* @param array<string, mixed>|null $bulkActions Bulk action definitions
* @param bool $searchable Enable search
*/
public function __construct(
public string $resource,
public string $resourceName,
public string $title,
public array $columns = [],
public bool $canCreate = true,
public bool $canEdit = true,
public bool $canView = true,
public bool $canDelete = true,
public ?array $filters = null,
public ?array $bulkActions = null,
public bool $searchable = true,
) {}
public static function forResource(
string $resource,
string $resourceName,
string $title
): self {
return new self(
resource: $resource,
resourceName: $resourceName,
title: $title
);
}
public function withColumns(array $columns): self
{
return new self(
resource: $this->resource,
resourceName: $this->resourceName,
title: $this->title,
columns: $columns,
canCreate: $this->canCreate,
canEdit: $this->canEdit,
canView: $this->canView,
canDelete: $this->canDelete,
filters: $this->filters,
bulkActions: $this->bulkActions,
searchable: $this->searchable
);
}
public function withPermissions(
bool $canCreate = true,
bool $canEdit = true,
bool $canView = true,
bool $canDelete = true
): self {
return new self(
resource: $this->resource,
resourceName: $this->resourceName,
title: $this->title,
columns: $this->columns,
canCreate: $canCreate,
canEdit: $canEdit,
canView: $canView,
canDelete: $canDelete,
filters: $this->filters,
bulkActions: $this->bulkActions,
searchable: $this->searchable
);
}
public function withFilters(array $filters): self
{
return new self(
resource: $this->resource,
resourceName: $this->resourceName,
title: $this->title,
columns: $this->columns,
canCreate: $this->canCreate,
canEdit: $this->canEdit,
canView: $this->canView,
canDelete: $this->canDelete,
filters: $filters,
bulkActions: $this->bulkActions,
searchable: $this->searchable
);
}
public function withBulkActions(array $bulkActions): self
{
return new self(
resource: $this->resource,
resourceName: $this->resourceName,
title: $this->title,
columns: $this->columns,
canCreate: $this->canCreate,
canEdit: $this->canEdit,
canView: $this->canView,
canDelete: $this->canDelete,
filters: $this->filters,
bulkActions: $bulkActions,
searchable: $this->searchable
);
}
public function toArray(): array
{
return [
'resource' => $this->resource,
'resourceName' => $this->resourceName,
'title' => $this->title,
'columns' => $this->columns,
'canCreate' => $this->canCreate,
'canEdit' => $this->canEdit,
'canView' => $this->canView,
'canDelete' => $this->canDelete,
'hasActions' => $this->canEdit || $this->canView || $this->canDelete,
'filters' => $this->filters,
'bulkActions' => $this->bulkActions,
'searchable' => $this->searchable,
];
}
}