Skip to content

Commit 52b66df

Browse files
committed
[TASK] Improve AI Label overview module
Replace the plain paginated table with a proper styled module: stat cards, table/origin/review-status/search filters, sortable columns (Record, Table, Author, Origin, Reviewed), record icons that open the context menu, and speaking table/type labels via the Schema API instead of raw table names. Also fixes AiLabelApi never being reachable via GeneralUtility::makeInstance() from outside its own DI graph (e.g. aim's AiLabelMiddleware), since it was never marked as a public service.
1 parent 668e54d commit 52b66df

18 files changed

Lines changed: 1294 additions & 104 deletions

File tree

Classes/Backend/SortUrlBuilder.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace B13\AiLabel\Backend;
6+
7+
/*
8+
* This file is part of TYPO3 CMS-based extension "ai_label" by b13.
9+
*
10+
* It is free software; you can redistribute it and/or modify it under
11+
* the terms of the GNU General Public License, either version 2
12+
* of the License, or any later version.
13+
*/
14+
15+
use B13\AiLabel\Domain\Repository\AiLabelDemand;
16+
use TYPO3\CMS\Backend\Routing\UriBuilder;
17+
18+
/**
19+
* Builds the asc/desc sort URLs per sortable column, preserving all active
20+
* filters. Coupled directly to AiLabelDemand rather than a shared interface:
21+
* this extension has exactly one sortable listing, so a generic abstraction
22+
* would be speculative.
23+
*/
24+
final class SortUrlBuilder
25+
{
26+
public function __construct(
27+
private readonly UriBuilder $uriBuilder,
28+
) {
29+
}
30+
31+
/**
32+
* @return array<string, array{ascUrl: string, descUrl: string, active: bool, direction: string}>
33+
*/
34+
public function build(AiLabelDemand $demand, string $route): array
35+
{
36+
$filterParams = [];
37+
foreach ($demand->getParameters() as $key => $value) {
38+
$filterParams['demand[' . $key . ']'] = $value;
39+
}
40+
41+
$sortUrls = [];
42+
foreach (AiLabelDemand::getOrderFields() as $field) {
43+
$isActive = $demand->getOrderField() === $field;
44+
$sortUrls[$field] = [
45+
'ascUrl' => (string)$this->uriBuilder->buildUriFromRoute($route, array_merge($filterParams, [
46+
'orderField' => $field,
47+
'orderDirection' => AiLabelDemand::ORDER_ASCENDING,
48+
])),
49+
'descUrl' => (string)$this->uriBuilder->buildUriFromRoute($route, array_merge($filterParams, [
50+
'orderField' => $field,
51+
'orderDirection' => AiLabelDemand::ORDER_DESCENDING,
52+
])),
53+
'active' => $isActive,
54+
'direction' => $isActive ? $demand->getOrderDirection() : '',
55+
];
56+
}
57+
return $sortUrls;
58+
}
59+
}

Classes/Controller/AiLabelOverviewController.php

Lines changed: 59 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,82 +12,107 @@
1212
* of the License, or any later version.
1313
*/
1414

15+
use B13\AiLabel\Backend\SortUrlBuilder;
16+
use B13\AiLabel\Domain\Repository\AiLabelDemand;
1517
use B13\AiLabel\Domain\Repository\AiMetadataRecordFinder;
18+
use B13\AiLabel\Pagination\DemandedArrayPaginator;
1619
use B13\AiLabel\Service\AiMetadataBadgeFactory;
1720
use Psr\Http\Message\ResponseInterface;
1821
use Psr\Http\Message\ServerRequestInterface;
1922
use TYPO3\CMS\Backend\Attribute\AsController;
2023
use TYPO3\CMS\Backend\Routing\UriBuilder;
24+
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
25+
use TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton;
2126
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
2227
use TYPO3\CMS\Core\Localization\LanguageService;
23-
use TYPO3\CMS\Core\Page\PageRenderer;
24-
use TYPO3\CMS\Core\Pagination\ArrayPaginator;
25-
use TYPO3\CMS\Core\Pagination\SlidingWindowPagination;
28+
use TYPO3\CMS\Core\Pagination\SimplePagination;
29+
use TYPO3\CMS\Core\Utility\GeneralUtility;
2630

2731
#[AsController]
2832
final class AiLabelOverviewController
2933
{
30-
private const ITEMS_PER_PAGE = 25;
31-
private const MAX_NUMBER_OF_LINKS = 7;
3234
private const MODULE_IDENTIFIER = 'web_ai_label_overview';
3335

3436
public function __construct(
3537
private readonly ModuleTemplateFactory $moduleTemplateFactory,
3638
private readonly AiMetadataRecordFinder $recordFinder,
3739
private readonly AiMetadataBadgeFactory $badgeFactory,
3840
private readonly UriBuilder $uriBuilder,
39-
private readonly PageRenderer $pageRenderer,
41+
private readonly SortUrlBuilder $sortUrlBuilder,
4042
) {
4143
}
4244

4345
public function handleRequest(ServerRequestInterface $request): ResponseInterface
4446
{
45-
$this->pageRenderer->addCssFile('EXT:ai_label/Resources/Public/Css/ai-label.css');
46-
$currentPageNumber = max(1, (int)($request->getQueryParams()['currentPage'] ?? 1));
47+
$view = $this->moduleTemplateFactory->create($request);
48+
$languageService = $this->getLanguageService();
49+
$view->setTitle($languageService->sL('LLL:EXT:ai_label/Resources/Private/Language/locallang_mod.xlf:mlang_tabs_tab'));
4750

48-
$returnUrl = (string)$request->getUri();
49-
$records = array_map(
50-
fn (array $record) => [
51+
$shortcutButton = GeneralUtility::makeInstance(ShortcutButton::class)
52+
->setRouteIdentifier(self::MODULE_IDENTIFIER)
53+
->setDisplayName($languageService->sL('LLL:EXT:ai_label/Resources/Private/Language/locallang_mod.xlf:mlang_tabs_tab'));
54+
$view->getDocHeaderComponent()->getButtonBar()->addButton($shortcutButton, ButtonBar::BUTTON_POSITION_RIGHT);
55+
56+
$demand = AiLabelDemand::fromRequest($request);
57+
$allRecords = $this->recordFinder->findFlaggedRecords();
58+
$statistics = $this->recordFinder->calculateStatistics($allRecords);
59+
$tables = $this->recordFinder->getDistinctTables($allRecords);
60+
61+
$matchingRecords = $this->recordFinder->filterAndSort($allRecords, $demand);
62+
$totalCount = count($matchingRecords);
63+
$pageItems = array_slice($matchingRecords, ($demand->getPage() - 1) * $demand->getLimit(), $demand->getLimit());
64+
65+
$returnUrl = $this->buildOverviewUrl($demand);
66+
$pageItems = array_map(
67+
fn (array $record): array => [
5168
...$record,
5269
'reviewBadge' => $this->badgeFactory->getBadge($record['metadata'], $this->buildEditUrl($record['table'], $record['uid'], $returnUrl)),
5370
],
54-
$this->recordFinder->findFlaggedRecords()
71+
$pageItems,
5572
);
5673

57-
$paginator = new ArrayPaginator($records, $currentPageNumber, self::ITEMS_PER_PAGE);
58-
$pagination = new SlidingWindowPagination($paginator, self::MAX_NUMBER_OF_LINKS);
74+
$paginator = new DemandedArrayPaginator($pageItems, $demand->getPage(), $demand->getLimit(), $totalCount);
75+
$pagination = new SimplePagination($paginator);
76+
$paginationBaseUrl = (string)$this->uriBuilder->buildUriFromRoute(self::MODULE_IDENTIFIER, $this->demandToRouteParams($demand));
5977

60-
$moduleTemplate = $this->moduleTemplateFactory->create($request);
61-
$moduleTemplate->setTitle(
62-
$this->getLanguageService()->sL('LLL:EXT:ai_label/Resources/Private/Language/locallang_mod.xlf:mlang_tabs_tab')
63-
);
64-
$moduleTemplate->assignMultiple([
78+
$view->assignMultiple([
79+
'demand' => $demand,
80+
// Filters are submitted via POST (Overview/Filters.html), so they never
81+
// show up in the request's own URI. It must be rebuilt from the parsed
82+
// demand instead, the same way $paginationBaseUrl is.
83+
'returnUrl' => $returnUrl,
84+
'paginationBaseUrl' => $paginationBaseUrl,
85+
'sortUrls' => $this->sortUrlBuilder->build($demand, self::MODULE_IDENTIFIER),
6586
'paginator' => $paginator,
6687
'pagination' => $pagination,
67-
'pageUris' => $this->buildPageUris($pagination),
68-
'previousPageUri' => $this->buildPageUri($pagination->getPreviousPageNumber()),
69-
'nextPageUri' => $this->buildPageUri($pagination->getNextPageNumber()),
88+
'statistics' => $statistics,
89+
'tables' => $tables,
7090
]);
7191

72-
return $moduleTemplate->renderResponse('Overview/Index');
92+
return $view->renderResponse('Overview/Index');
7393
}
7494

75-
/** @return array<int, string> */
76-
private function buildPageUris(SlidingWindowPagination $pagination): array
95+
/**
96+
* @return array<string, string>
97+
*/
98+
private function demandToRouteParams(AiLabelDemand $demand): array
7799
{
78-
$uris = [];
79-
foreach ($pagination->getAllPageNumbers() as $pageNumber) {
80-
$uris[$pageNumber] = (string)$this->buildPageUri($pageNumber);
100+
$params = [
101+
'orderField' => $demand->getOrderField(),
102+
'orderDirection' => $demand->getOrderDirection(),
103+
];
104+
foreach ($demand->getParameters() as $key => $value) {
105+
$params['demand[' . $key . ']'] = $value;
81106
}
82-
return $uris;
107+
return $params;
83108
}
84109

85-
private function buildPageUri(?int $pageNumber): ?string
110+
private function buildOverviewUrl(AiLabelDemand $demand): string
86111
{
87-
if ($pageNumber === null) {
88-
return null;
89-
}
90-
return (string)$this->uriBuilder->buildUriFromRoute(self::MODULE_IDENTIFIER, ['currentPage' => $pageNumber]);
112+
return (string)$this->uriBuilder->buildUriFromRoute(
113+
self::MODULE_IDENTIFIER,
114+
array_merge($this->demandToRouteParams($demand), ['page' => $demand->getPage()]),
115+
);
91116
}
92117

93118
private function buildEditUrl(string $table, int $uid, string $returnUrl): string
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace B13\AiLabel\Domain\Repository;
6+
7+
/*
8+
* This file is part of TYPO3 CMS-based extension "ai_label" by b13.
9+
*
10+
* It is free software; you can redistribute it and/or modify it under
11+
* the terms of the GNU General Public License, either version 2
12+
* of the License, or any later version.
13+
*/
14+
15+
use Psr\Http\Message\ServerRequestInterface;
16+
17+
/**
18+
* Filter/sort/pagination state for the AI Label overview module. Plain,
19+
* immutable value object parsed once from the request, then threaded
20+
* through the repository, SortUrlBuilder, and pagination base URL builder.
21+
*/
22+
final class AiLabelDemand
23+
{
24+
public const ORDER_ASCENDING = 'asc';
25+
public const ORDER_DESCENDING = 'desc';
26+
private const DEFAULT_ORDER_FIELD = 'reviewed';
27+
28+
/**
29+
* Records needing review bubble to the top by default. reviewed=false
30+
* sorts before reviewed=true ascending, which fits this module's whole
31+
* purpose better than an alphabetical table-name default would.
32+
*/
33+
private const ORDER_FIELDS = ['table', 'title', 'author', 'origin', 'reviewed'];
34+
35+
private const ORIGIN_VALUES = ['created', 'modified'];
36+
private const REVIEW_STATUS_VALUES = ['required', 'reviewed'];
37+
38+
private int $limit = 25;
39+
40+
public function __construct(
41+
private int $page = 1,
42+
private string $orderField = self::DEFAULT_ORDER_FIELD,
43+
private string $orderDirection = self::ORDER_ASCENDING,
44+
private string $table = '',
45+
private string $origin = '',
46+
private string $reviewStatus = '',
47+
private string $search = '',
48+
) {
49+
if (!in_array($orderField, self::ORDER_FIELDS, true)) {
50+
$orderField = self::DEFAULT_ORDER_FIELD;
51+
}
52+
$this->orderField = $orderField;
53+
if (!in_array($orderDirection, [self::ORDER_ASCENDING, self::ORDER_DESCENDING], true)) {
54+
$orderDirection = self::ORDER_ASCENDING;
55+
}
56+
$this->orderDirection = $orderDirection;
57+
if (!in_array($origin, self::ORIGIN_VALUES, true)) {
58+
$this->origin = '';
59+
}
60+
if (!in_array($reviewStatus, self::REVIEW_STATUS_VALUES, true)) {
61+
$this->reviewStatus = '';
62+
}
63+
$this->page = max(1, $page);
64+
}
65+
66+
public static function fromRequest(ServerRequestInterface $request): self
67+
{
68+
$page = (int)($request->getQueryParams()['page'] ?? $request->getParsedBody()['page'] ?? 1);
69+
$orderField = (string)($request->getQueryParams()['orderField'] ?? $request->getParsedBody()['orderField'] ?? self::DEFAULT_ORDER_FIELD);
70+
$orderDirection = (string)($request->getQueryParams()['orderDirection'] ?? $request->getParsedBody()['orderDirection'] ?? self::ORDER_ASCENDING);
71+
$demand = $request->getQueryParams()['demand'] ?? $request->getParsedBody()['demand'] ?? [];
72+
if (!is_array($demand)) {
73+
$demand = [];
74+
}
75+
76+
return new self(
77+
$page,
78+
$orderField,
79+
$orderDirection,
80+
(string)($demand['table'] ?? ''),
81+
(string)($demand['origin'] ?? ''),
82+
(string)($demand['review_status'] ?? ''),
83+
trim((string)($demand['search'] ?? '')),
84+
);
85+
}
86+
87+
/**
88+
* @return list<string>
89+
*/
90+
public static function getOrderFields(): array
91+
{
92+
return self::ORDER_FIELDS;
93+
}
94+
95+
public function getPage(): int
96+
{
97+
return $this->page;
98+
}
99+
100+
public function getLimit(): int
101+
{
102+
return $this->limit;
103+
}
104+
105+
public function getOrderField(): string
106+
{
107+
return $this->orderField;
108+
}
109+
110+
public function getOrderDirection(): string
111+
{
112+
return $this->orderDirection;
113+
}
114+
115+
public function getTable(): string
116+
{
117+
return $this->table;
118+
}
119+
120+
public function hasTable(): bool
121+
{
122+
return $this->table !== '';
123+
}
124+
125+
public function getOrigin(): string
126+
{
127+
return $this->origin;
128+
}
129+
130+
public function hasOrigin(): bool
131+
{
132+
return $this->origin !== '';
133+
}
134+
135+
public function getReviewStatus(): string
136+
{
137+
return $this->reviewStatus;
138+
}
139+
140+
public function hasReviewStatus(): bool
141+
{
142+
return $this->reviewStatus !== '';
143+
}
144+
145+
public function getSearch(): string
146+
{
147+
return $this->search;
148+
}
149+
150+
public function hasSearch(): bool
151+
{
152+
return $this->search !== '';
153+
}
154+
155+
public function hasConstraints(): bool
156+
{
157+
return $this->hasTable() || $this->hasOrigin() || $this->hasReviewStatus() || $this->hasSearch();
158+
}
159+
160+
/**
161+
* Active filter parameters (not sorting/paging), reused by SortUrlBuilder
162+
* and the pagination base URL so neither one drops the current filter.
163+
*
164+
* @return array<string, string>
165+
*/
166+
public function getParameters(): array
167+
{
168+
$parameters = [];
169+
if ($this->hasTable()) {
170+
$parameters['table'] = $this->table;
171+
}
172+
if ($this->hasOrigin()) {
173+
$parameters['origin'] = $this->origin;
174+
}
175+
if ($this->hasReviewStatus()) {
176+
$parameters['review_status'] = $this->reviewStatus;
177+
}
178+
if ($this->hasSearch()) {
179+
$parameters['search'] = $this->search;
180+
}
181+
return $parameters;
182+
}
183+
}

0 commit comments

Comments
 (0)