Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.88% covered (success)
96.88%
93 / 96
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
GridWidgetApiController
96.84% covered (success)
96.84%
92 / 95
66.67% covered (warning)
66.67%
4 / 6
16
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 actionGridData
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
4
 actionRenderWidget
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
5.07
 actionSavePositions
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
4
 jsonResponse
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 errorResponse
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Engine\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\PermissionContext;
12use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
13use App\Core\Grid\Widget\WidgetRegistry;
14use App\Shared\Infrastructure\Http\ApiResponseTrait;
15use PDO;
16use Psr\Http\Message\ResponseFactoryInterface;
17use Psr\Http\Message\ResponseInterface;
18use Psr\Http\Message\ServerRequestInterface;
19
20/**
21 * Grid Widget REST API Controller.
22 *
23 * Serves GRID layout metadata, widget tile HTML rendering, and dynamic tile updates.
24 *
25 * Routes:
26 *   GET  /api/v1/engine/{module}/grid                      -> actionGridData()
27 *   GET  /api/v1/engine/{module}/widget/{relationId}/render -> actionRenderWidget()
28 *   POST /api/v1/engine/{module}/widget/positions          -> actionSavePositions()
29 *
30 * @package App\Core\Engine\Presentation\Api
31 */
32final readonly class GridWidgetApiController
33{
34    use ApiResponseTrait;
35
36    /**
37     * GridWidgetApiController constructor.
38     *
39     * @param MetadataRepositoryInterface $metadataRepository Metadata repository.
40     * @param WidgetRegistry              $widgetRegistry     Widget renderer registry.
41     * @param ResponseFactoryInterface    $responseFactory    PSR-7 Response factory.
42     * @param PDO                         $pdo                PDO connection for layout position updates.
43     */
44    public function __construct(
45        private MetadataRepositoryInterface $metadataRepository,
46        private WidgetRegistry              $widgetRegistry,
47        private ResponseFactoryInterface    $responseFactory,
48        private PDO                         $pdo,
49    ) {
50    }
51
52    /**
53     * Returns JSON structure with grid filters, widgets and placement metadata.
54     *
55     * @param ServerRequestInterface $request    Server request.
56     * @param string                 $moduleName Module machine name.
57     * @param PermissionContext      $context    Security context.
58     * @return ResponseInterface JSON API response.
59     */
60    public function actionGridData(
61        ServerRequestInterface $request,
62        string                 $moduleName,
63        PermissionContext      $context
64    ): ResponseInterface {
65        try {
66            $module       = $this->metadataRepository->findModule($moduleName);
67            $queryParams  = $request->getQueryParams();
68            $filterGridId = isset($queryParams['grid_filter']) ? (int) $queryParams['grid_filter'] : null;
69
70            $activeFilter = $this->metadataRepository->findFilterGrid($module->id, $filterGridId);
71            $gridFilters  = $this->metadataRepository->findModuleFiltersGrid($module->id, $context);
72            $relations    = $this->metadataRepository->findGridRelationsByModule($module->id, $activeFilter->id);
73
74            $widgetsData = [];
75            foreach ($relations as $rel) {
76                $html = $this->widgetRegistry->renderWidget($rel, $request, $context);
77                $widgetsData[] = [
78                    'relation_id'   => $rel->id,
79                    'name'          => $rel->name,
80                    'label'         => $rel->label,
81                    'widget_id'     => $rel->widgetId,
82                    'pos_x'         => $rel->posX,
83                    'pos_y'         => $rel->posY,
84                    'width'         => $rel->width,
85                    'height'        => $rel->height,
86                    'is_locked'     => $rel->isLocked,
87                    'widget_params' => $rel->widgetParams,
88                    'html'          => $html,
89                ];
90            }
91
92            return $this->jsonResponse([
93                'status'  => true,
94                'message' => 'Grid data retrieved successfully.',
95                'data'    => [
96                    'module'         => [
97                        'id'    => $module->id,
98                        'name'  => $module->name,
99                        'label' => $module->label,
100                        'type'  => $module->type,
101                    ],
102                    'active_filter'  => [
103                        'id'         => $activeFilter->id,
104                        'name'       => $activeFilter->name,
105                        'label'      => $activeFilter->label,
106                        'icon_class' => $activeFilter->iconClass,
107                    ],
108                    'grid_filters'   => array_map(static fn($f): array => [
109                        'id'         => $f->id,
110                        'name'       => $f->name,
111                        'label'      => $f->label,
112                        'icon_class' => $f->iconClass,
113                        'is_default' => $f->isDefault,
114                    ], $gridFilters),
115                    'widgets'        => $widgetsData,
116                ],
117            ]);
118        } catch (\Throwable $e) {
119            return $this->errorResponse($e->getMessage(), 500);
120        }
121    }
122
123    /**
124     * Renders a single widget tile HTML for HTMX or dynamic SSE/WebSocket refresh.
125     *
126     * @param ServerRequestInterface $request    Server request.
127     * @param string                 $moduleName Module machine name.
128     * @param int                    $relationId Relation record primary key.
129     * @param PermissionContext      $context    Security context.
130     * @return ResponseInterface HTML or JSON response.
131     */
132    public function actionRenderWidget(
133        ServerRequestInterface $request,
134        string                 $moduleName,
135        int                    $relationId,
136        PermissionContext      $context
137    ): ResponseInterface {
138        try {
139            $module = $this->metadataRepository->findModule($moduleName);
140            $relations = $this->metadataRepository->findGridRelationsByModule($module->id);
141
142            $matchedRelation = null;
143            foreach ($relations as $rel) {
144                if ($rel->id === $relationId) {
145                    $matchedRelation = $rel;
146                    break;
147                }
148            }
149
150            if ($matchedRelation === null) {
151                return $this->errorResponse('Widget relation not found.', 404);
152            }
153
154            $html = $this->widgetRegistry->renderWidget($matchedRelation, $request, $context);
155            $response = $this->responseFactory->createResponse(200);
156            $response->getBody()->write($html);
157            return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
158        } catch (\Throwable $e) {
159            return $this->errorResponse($e->getMessage(), 500);
160        }
161    }
162
163    /**
164     * Persists updated Gridstack widget positions (pos_x, pos_y, width, height).
165     *
166     * @param ServerRequestInterface $request Server request containing positions JSON.
167     * @return ResponseInterface JSON confirmation response.
168     */
169    public function actionSavePositions(ServerRequestInterface $request): ResponseInterface
170    {
171        $body = (string) $request->getBody();
172        /** @var array<string, mixed> $data */
173        $data = json_decode($body, true) ?? [];
174        $items = $data['items'] ?? [];
175
176        if (!is_array($items)) {
177            return $this->errorResponse('Invalid grid data format.', 422);
178        }
179
180        $stmt = $this->pdo->prepare('
181            UPDATE `a_core_relation_grid_records`
182            SET    `pos_x` = :pos_x,
183                   `pos_y` = :pos_y,
184                   `width` = :width,
185                   `height` = :height
186            WHERE  `id` = :id
187        ');
188
189        foreach ($items as $item) {
190            if (isset($item['relation_id'], $item['x'], $item['y'], $item['w'], $item['h'])) {
191                $stmt->execute([
192                    ':id'     => (int) $item['relation_id'],
193                    ':pos_x'  => (int) $item['x'],
194                    ':pos_y'  => (int) $item['y'],
195                    ':width'  => (int) $item['w'],
196                    ':height' => (int) $item['h'],
197                ]);
198            }
199        }
200
201        $this->metadataRepository->clearCache();
202
203        return $this->jsonResponse([
204            'status'  => true,
205            'message' => 'Widget positions updated successfully.',
206            'data'    => [],
207        ]);
208    }
209
210    /**
211     * Creates a JSON HTTP response.
212     *
213     * @param array<string, mixed> $payload Response payload data.
214     * @param int                  $status  HTTP status code.
215     * @return ResponseInterface PSR-7 JSON response.
216     */
217    private function jsonResponse(array $payload, int $status = 200): ResponseInterface
218    {
219        $response = $this->responseFactory->createResponse($status);
220        $response->getBody()->write((string) json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
221        return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
222    }
223
224    /**
225     * Creates an error JSON response.
226     *
227     * @param string $message Error message.
228     * @param int    $status  HTTP status code.
229     * @return ResponseInterface PSR-7 JSON error response.
230     */
231    private function errorResponse(string $message, int $status = 400): ResponseInterface
232    {
233        return $this->jsonResponse([
234            'status'  => false,
235            'message' => $message,
236            'data'    => [],
237        ], $status);
238    }
239}