diff --git a/.ddev/commands/host/initialize b/.ddev/commands/host/initialize index 5b4c965b..6b8ca8d3 100755 --- a/.ddev/commands/host/initialize +++ b/.ddev/commands/host/initialize @@ -18,9 +18,9 @@ mkdir -p config/sites/main cp .project/config/typo3/config.yaml config/sites/main/ mkdir -p .build/public cp .project/config/typo3/.htaccess .build/public/ -mkdir -p .build/public/typo3conf -cp .project/config/typo3/LocalConfiguration.php .build/public/typo3conf -cp .project/config/typo3/AdditionalConfiguration.php .build/public/typo3conf +mkdir -p config/system +cp .project/config/typo3/settings.php config/system/ +cp .project/config/typo3/additional.php config/system/ echo "Importing database" ddev import-db --src=.project/data/db.sql.gz diff --git a/.ddev/config.yaml b/.ddev/config.yaml index 30d6b874..96b633e7 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -1,7 +1,7 @@ name: in2studyfinder type: php docroot: .build/public -php_version: "8.0" +php_version: "8.1" webserver_type: apache-fpm router_http_port: "80" router_https_port: "443" diff --git a/.gitignore b/.gitignore index 2000f375..18e6101f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ var/ !.ddev/config.yaml !.ddev/docker-compose.typo3.yaml +!.project/config diff --git a/.phpcs.xml b/.phpcs.xml index f7c6456c..531ed0ec 100644 --- a/.phpcs.xml +++ b/.phpcs.xml @@ -10,7 +10,7 @@ Classes - + diff --git a/.phpmd.xml b/.phpmd.xml index f7135217..a5ae14ba 100644 --- a/.phpmd.xml +++ b/.phpmd.xml @@ -19,7 +19,7 @@ - + diff --git a/.project/config/typo3/.htaccess b/.project/config/typo3/.htaccess index 8d8bd60f..d39e2af8 100644 --- a/.project/config/typo3/.htaccess +++ b/.project/config/typo3/.htaccess @@ -33,13 +33,13 @@ # *) Set $GLOBALS['TYPO3_CONF_VARS']['FE']['compressionLevel'] = 9 together with the TypoScript properties # config.compressJs and config.compressCss for GZIP compression of Frontend JS and CSS files. -# -# AddType "text/javascript" .gzip +# +# AddType "text/javascript" .gz # -# -# AddType "text/css" .gzip +# +# AddType "text/css" .gz # -#AddEncoding gzip .gzip +#AddEncoding x-gzip .gz # Force compression for mangled `Accept-Encoding` request headers @@ -111,7 +111,7 @@ # This affects Frontend and Backend and increases performance. - ExpiresActive on + ExpiresActive On ExpiresDefault "access plus 1 month" ExpiresByType text/css "access plus 1 year" @@ -259,7 +259,7 @@ AddDefaultCharset utf-8 # Send the CORS header for images when browsers request it. - + SetEnvIf Origin ":" IS_CORS Header set Access-Control-Allow-Origin "*" env=IS_CORS @@ -279,8 +279,6 @@ AddDefaultCharset utf-8 ### Begin: Rewriting and Access ### -# You need rewriting, if you use a URL-Rewriting extension (RealURL, CoolUri). - # Enable URL rewriting @@ -305,7 +303,7 @@ AddDefaultCharset utf-8 # IMPORTANT: This rule has to be the very first RewriteCond in order to work! RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d - RewriteRule ^(.+)\.(\d+)\.(php|js|css|png|jpg|gif|gzip)$ %{ENV:CWD}$1.$3 [L] + RewriteRule ^(.+)\.(\d+)\.(php|js|css|png|jpg|gif|gz)$ %{ENV:CWD}$1.$3 [L] # Access block for folders RewriteRule _(?:recycler|temp)_/ - [F] @@ -377,7 +375,7 @@ Options -MultiViews # Force IE to render pages in the highest available mode Header set X-UA-Compatible "IE=edge" - + Header unset X-UA-Compatible diff --git a/.project/config/typo3/AdditionalConfiguration.php b/.project/config/typo3/additional.php similarity index 100% rename from .project/config/typo3/AdditionalConfiguration.php rename to .project/config/typo3/additional.php diff --git a/.project/config/typo3/LocalConfiguration.php b/.project/config/typo3/settings.php similarity index 98% rename from .project/config/typo3/LocalConfiguration.php rename to .project/config/typo3/settings.php index eb58abd9..5f97a7ec 100644 --- a/.project/config/typo3/LocalConfiguration.php +++ b/.project/config/typo3/settings.php @@ -1,7 +1,7 @@ [ - 'debug' => false, + 'debug' => true, 'explicitADmode' => 'explicitAllow', 'installToolPassword' => '$argon2i$v=19$m=65536,t=16,p=1$Y0Zkc2tFVUFzaWs3S1JUMA$VUYXEG49usrpbDP9cKfC0GrhRAtGICi5B7HGJ4LVYtg', 'passwordHashing' => [ @@ -53,7 +53,7 @@ ], ], 'FE' => [ - 'debug' => false, + 'debug' => true, 'disableNoCacheParameter' => true, 'passwordHashing' => [ 'className' => 'TYPO3\\CMS\\Core\\Crypto\\PasswordHashing\\Argon2iPasswordHash', diff --git a/Classes/Controller/BackendController.php b/Classes/Controller/BackendController.php index 08ad7344..af124fc0 100644 --- a/Classes/Controller/BackendController.php +++ b/Classes/Controller/BackendController.php @@ -7,33 +7,41 @@ use In2code\In2studyfinder\Domain\Repository\StudyCourseRepository; use In2code\In2studyfinder\Domain\Service\CourseService; use In2code\In2studyfinder\Service\ExportService; +use Psr\Http\Message\ResponseInterface; +use TYPO3\CMS\Backend\Template\ModuleTemplateFactory; use TYPO3\CMS\Core\Database\ConnectionPool; -use TYPO3\CMS\Core\Messaging\AbstractMessage; +use TYPO3\CMS\Core\Page\PageRenderer; +use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Utility\LocalizationUtility; /** * @SuppressWarnings(PHPMD.LongVariable) + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class BackendController extends AbstractController { protected CourseService $courseService; - - /** - * @var StudyCourseRepository - */ - protected $studyCourseRepository; - - public function __construct(StudyCourseRepository $studyCourseRepository, CourseService $courseService) - { + protected StudyCourseRepository $studyCourseRepository; + protected ModuleTemplateFactory $moduleTemplateFactory; + protected PageRenderer $pageRenderer; + + public function __construct( + StudyCourseRepository $studyCourseRepository, + CourseService $courseService, + ModuleTemplateFactory $moduleTemplateFactory, + PageRenderer $pageRenderer + ) { $this->studyCourseRepository = $studyCourseRepository; $this->courseService = $courseService; + $this->moduleTemplateFactory = $moduleTemplateFactory; + $this->pageRenderer = $pageRenderer; } /** * @throws \TYPO3\CMS\Extbase\Mvc\Exception\NoSuchArgumentException */ - public function listAction(): void + public function listAction(): ResponseInterface { $this->validateSettings(); @@ -57,7 +65,7 @@ public function listAction(): void $this->addFlashMessage( LocalizationUtility::translate('messages.noCourses.body', 'in2studyfinder'), LocalizationUtility::translate('messages.noCourses.title', 'in2studyfinder'), - AbstractMessage::WARNING + ContextualFeedbackSeverity::WARNING ); } else { $propertyArray = @@ -82,6 +90,13 @@ public function listAction(): void 'itemsPerPage' => $itemsPerPage ] ); + + $moduleTemplate = $this->moduleTemplateFactory->create($this->request); + $this->pageRenderer->loadRequireJsModule('TYPO3/CMS/In2studyfinder/Backend/Backend'); + $this->pageRenderer->addCssFile('EXT:in2studyfinder/Resources/Public/Css/backend.css'); + $moduleTemplate->setContent($this->view->render()); + + return $this->htmlResponse($moduleTemplate->renderContent()); } /** @@ -98,7 +113,7 @@ public function exportAction( $this->addFlashMessage( LocalizationUtility::translate('messages.notAllRequiredFieldsSet.body', 'in2studyfinder'), LocalizationUtility::translate('messages.notAllRequiredFieldsSet.title', 'in2studyfinder'), - AbstractMessage::ERROR + ContextualFeedbackSeverity::ERROR ); $this->forward('list'); @@ -124,7 +139,7 @@ protected function getSysLanguages(): array ->from('sys_language') ->where($queryBuilder->expr()->eq('hidden', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))) ->orderBy('sorting') - ->execute()->fetchAll(); + ->executeQuery()->fetchAllAssociative(); foreach ($languageRecords as $languageRecord) { $sysLanguages[(int)$languageRecord['uid']] = @@ -153,7 +168,7 @@ protected function getPossibleExportDataProvider(): array $this->addFlashMessage( 'export provider class "' . $providerClass . '" was not found', 'export provider class not found', - AbstractMessage::ERROR + ContextualFeedbackSeverity::ERROR ); } else { $possibleDataProvider[$providerName] = $providerClass; @@ -170,7 +185,7 @@ protected function validateSettings(): void $this->addFlashMessage( LocalizationUtility::translate('messages.noStoragePid.body', 'in2studyfinder'), LocalizationUtility::translate('messages.noStoragePid.title', 'in2studyfinder'), - AbstractMessage::ERROR + ContextualFeedbackSeverity::ERROR ); } @@ -178,7 +193,7 @@ protected function validateSettings(): void $this->addFlashMessage( LocalizationUtility::translate('messages.noSettingsPid.body', 'in2studyfinder'), LocalizationUtility::translate('messages.noSettingsPid.title', 'in2studyfinder'), - AbstractMessage::ERROR + ContextualFeedbackSeverity::ERROR ); } } diff --git a/Classes/Controller/StudyCourseController.php b/Classes/Controller/StudyCourseController.php index e22865d4..9385e0b6 100644 --- a/Classes/Controller/StudyCourseController.php +++ b/Classes/Controller/StudyCourseController.php @@ -14,6 +14,7 @@ use In2code\In2studyfinder\Utility\FlexFormUtility; use In2code\In2studyfinder\Utility\FrontendUtility; use In2code\In2studyfinder\Utility\RecordUtility; +use Psr\Http\Message\ResponseInterface; use TYPO3\CMS\Core\Utility\GeneralUtility; /** @@ -39,40 +40,12 @@ public function __construct( } /** - * Strip empty options from incoming (selected) filters - * - * @throws \TYPO3\CMS\Extbase\Mvc\Exception\NoSuchArgumentException + * @param array $pluginInformation contains additional plugin information from ajax / fetch requests */ - public function initializeFilterAction(): void + public function filterAction(array $searchOptions = [], array $pluginInformation = []): ResponseInterface { $this->filterService->initialize(); - if ($this->request->hasArgument('searchOptions')) { - $searchOptions = array_filter((array)$this->request->getArgument('searchOptions')); - $this->request->setArgument('searchOptions', $searchOptions); - - if (ConfigurationUtility::isPersistentFilterEnabled()) { - FrontendUtility::getTyposcriptFrontendController() - ->fe_user - ->setAndSaveSessionData('tx_in2studycourse_filter', $searchOptions); - } - } else { - if (ConfigurationUtility::isPersistentFilterEnabled()) { - $this->request->setArgument( - 'searchOptions', - FrontendUtility::getTyposcriptFrontendController() - ->fe_user - ->getSessionData('tx_in2studycourse_filter') - ); - } - } - } - - /** - * @param array $pluginInformation contains additional plugin information from ajax / fetch requests - */ - public function filterAction(array $searchOptions = [], array $pluginInformation = []): void - { if (!empty($pluginInformation)) { // if the current call is an ajax / fetch request $currentPluginRecord = @@ -90,8 +63,15 @@ public function filterAction(array $searchOptions = [], array $pluginInformation $currentPluginRecord = $this->configurationManager->getContentObject()->data; } + $this->filterService->setSettings($this->settings); + $searchOptions = $this->filterService->sanitizeSearch($searchOptions); + + if (ConfigurationUtility::isPersistentFilterEnabled()) { + $searchOptions = $this->filterService->loadOrSetPersistedFilter($searchOptions); + } + $studyCourses = $this->courseService->findBySearchOptions( - $this->filterService->setSettings($this->settings)->prepareSearchOptions($searchOptions), + $this->filterService->resolveFilterPropertyPath($searchOptions), $currentPluginRecord ); @@ -106,12 +86,14 @@ public function filterAction(array $searchOptions = [], array $pluginInformation 'data' => $currentPluginRecord ] ); + + return $this->htmlResponse(); } /** * fastSearchAction */ - public function fastSearchAction(): void + public function fastSearchAction(): ResponseInterface { $currentPluginRecord = $this->configurationManager->getContentObject()->data; $studyCourses = @@ -126,6 +108,8 @@ public function fastSearchAction(): void 'data' => $currentPluginRecord ] ); + + return $this->htmlResponse(); } /** @@ -156,7 +140,7 @@ public function initializeDetailAction(): void * * @throws \TYPO3\CMS\Extbase\Mvc\Exception\StopActionException */ - public function detailAction(StudyCourse $studyCourse = null): void + public function detailAction(StudyCourse $studyCourse = null): ResponseInterface { if ($studyCourse) { $this->courseService->setPageTitleAndMetadata($studyCourse); @@ -166,5 +150,7 @@ public function detailAction(StudyCourse $studyCourse = null): void } else { $this->redirect('filterAction', null, null, null, $this->settings['flexform']['studyCourseListPage']); } + + return $this->htmlResponse(); } } diff --git a/Classes/Domain/Repository/StudyCourseRepository.php b/Classes/Domain/Repository/StudyCourseRepository.php index a3f4e48d..aed0510d 100644 --- a/Classes/Domain/Repository/StudyCourseRepository.php +++ b/Classes/Domain/Repository/StudyCourseRepository.php @@ -41,18 +41,14 @@ public function findAllFilteredByOptions($options): QueryResultInterface foreach ($options as $name => $array) { if ($array[0] === 'true') { $constraints[] = $query->logicalOr( - [ - $query->logicalNot($query->equals($name, '')), - $query->greaterThan($name, 0), - ] + $query->logicalNot($query->equals($name, '')), + $query->greaterThan($name, 0), ); } elseif ($array[0] === 'false') { $constraints[] = $query->logicalOr( - [ - $query->equals($name, 0), - $query->equals($name, ''), - $query->equals($name, null), - ] + $query->equals($name, 0), + $query->equals($name, ''), + $query->equals($name, null), ); } else { $constraints[] = $query->in($name . '.uid', $array); @@ -60,7 +56,7 @@ public function findAllFilteredByOptions($options): QueryResultInterface } if (!empty($constraints)) { - $query->matching($query->logicalAnd($constraints)); + $query->matching($query->logicalAnd(...$constraints)); } return $query->execute(); @@ -116,7 +112,7 @@ public function findByUidsAndLanguage(array $uids, int $sysLanguageUid): QueryRe $constraints[] = $query->equals('sysLanguageUid', $sysLanguageUid); } - $query->matching($query->logicalAnd($constraints)); + $query->matching($query->logicalAnd(...$constraints)); return $query->execute(); } diff --git a/Classes/Event/ManipulateCsvPropertyBeforeExportEvent.php b/Classes/Event/ManipulateCsvPropertyBeforeExportEvent.php new file mode 100644 index 00000000..bc30e341 --- /dev/null +++ b/Classes/Event/ManipulateCsvPropertyBeforeExportEvent.php @@ -0,0 +1,25 @@ +property = $property; + } + + public function getProperty(): mixed + { + return $this->property; + } + + public function setProperty(mixed $property): void + { + $this->property = $property; + } +} diff --git a/Classes/Export/AbstractExport.php b/Classes/Export/AbstractExport.php index 5cbb6dda..d9388105 100644 --- a/Classes/Export/AbstractExport.php +++ b/Classes/Export/AbstractExport.php @@ -5,23 +5,18 @@ namespace In2code\In2studyfinder\Export; use In2code\In2studyfinder\Export\Configuration\ExportConfiguration; +use Psr\EventDispatcher\EventDispatcherInterface; use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Extbase\SignalSlot\Dispatcher; use TYPO3\CMS\Fluid\View\StandaloneView; class AbstractExport implements ExportInterface { protected string $fileExtension = ''; - - /** - * @var Dispatcher - */ - protected Dispatcher $signalSlotDispatcher; + protected EventDispatcherInterface $eventDispatcher; public function __construct() { - // @todo replace signal with psr 14 event - $this->signalSlotDispatcher = GeneralUtility::makeInstance(Dispatcher::class); + $this->eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class); } public function export(ExportConfiguration $exportConfiguration): string diff --git a/Classes/Export/ExportTypes/CsvExport.php b/Classes/Export/ExportTypes/CsvExport.php index 78361692..78d3017d 100644 --- a/Classes/Export/ExportTypes/CsvExport.php +++ b/Classes/Export/ExportTypes/CsvExport.php @@ -4,6 +4,7 @@ namespace In2code\In2studyfinder\Export\ExportTypes; +use In2code\In2studyfinder\Event\ManipulateCsvPropertyBeforeExportEvent; use In2code\In2studyfinder\Export\AbstractExport; use In2code\In2studyfinder\Export\Configuration\ExportConfiguration; use In2code\In2studyfinder\Export\ExportInterface; @@ -17,19 +18,16 @@ class CsvExport extends AbstractExport implements ExportInterface /** * @throws Exception - * @throws \TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotException - * @throws \TYPO3\CMS\Extbase\SignalSlot\Exception\InvalidSlotReturnException */ public function export(ExportConfiguration $exportConfiguration): string { $recordRows = []; foreach ($exportConfiguration->getRecordsToExport() as $row => $record) { - foreach (array_values($record) as $property) { - $this->signalSlotDispatcher->dispatch( - self::class, - 'manipulatePropertyBeforeExport', - [&$property] - ); + $recordRows[$row] = ''; + foreach ($record as $property) { + $property = $this->eventDispatcher->dispatch( + new ManipulateCsvPropertyBeforeExportEvent($property) + )->getProperty(); $recordRows[$row] .= '"' . $property . '";'; } diff --git a/Classes/Service/FilterService.php b/Classes/Service/FilterService.php index 486c6efa..e88f8a47 100644 --- a/Classes/Service/FilterService.php +++ b/Classes/Service/FilterService.php @@ -6,6 +6,7 @@ use In2code\In2studyfinder\Domain\Model\StudyCourseInterface; use In2code\In2studyfinder\Utility\ExtensionUtility; +use In2code\In2studyfinder\Utility\FrontendUtility; use Psr\Log\LoggerInterface; use TYPO3\CMS\Core\Utility\ClassNamingUtility; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -47,27 +48,39 @@ public function getFilter(): array } /** - * removes not allowed keys empty values from searchOptions and updates the filter keys to the actual property path + * removes not allowed keys and empty values from searchOptions */ - public function prepareSearchOptions(array $searchOptions): array + public function sanitizeSearch(array $searchOptions): array { // merge plugin restrictions to search options $searchOptions = array_merge($searchOptions, $this->getPluginFilterRestrictions()); $this->disableFilterFrontendRenderingByPluginRestrictions(); $filter = $this->getFilter(); - // 1. remove not allowed keys - foreach ($searchOptions as $filterName => $filterValues) { + // remove not allowed keys + foreach (array_keys($searchOptions) as $filterName) { if (!array_key_exists($filterName, $filter)) { unset($searchOptions[$filterName]); } } - // 2. remove empty values - $searchOptions = array_map('array_filter', $searchOptions); - $searchOptions = array_filter($searchOptions); + // remove empty values + foreach ($searchOptions as $optionName => $optionValue) { + if (empty($optionValue)) { + unset($searchOptions[$optionName]); + } + } + + return $searchOptions; + } + + /** + * updates the filter keys to the actual property path + */ + public function resolveFilterPropertyPath($searchOptions): array + { + $filter = $this->getFilter(); - // 3. set filter propertyPath as filter array key foreach ($searchOptions as $filterName => $filterValues) { $searchOptions[$filter[$filterName]['propertyPath']] = $filterValues; if ($filter[$filterName]['propertyPath'] !== $filterName) { @@ -78,6 +91,24 @@ public function prepareSearchOptions(array $searchOptions): array return $searchOptions; } + public function loadOrSetPersistedFilter(array $searchOptions): array + { + if (!empty($searchOptions)) { + FrontendUtility::getTyposcriptFrontendController() + ->fe_user + ->setAndSaveSessionData('tx_in2studycourse_filter', array_filter($searchOptions)); + } else { + $sessionData = FrontendUtility::getTyposcriptFrontendController() + ->fe_user + ->getSessionData('tx_in2studycourse_filter'); + if (!empty($sessionData)) { + return (array)$sessionData; + } + } + + return $searchOptions; + } + public function setSettings(array $settings): FilterService { $this->settings = $settings; @@ -175,11 +206,7 @@ protected function buildObjectFilter(string $filterName, array $filterConfigurat $defaultQuerySettings->setStoragePageIds([$this->settings['settingsPid']]); $defaultQuerySettings->setLanguageOverlayMode(true); - // In TYPO3 11 repositories still need the Object Manager for initialization - // this will change with TYPO3 12 - $objectManager = GeneralUtility::makeInstance(ObjectManager::class); - $repository = $objectManager->get($repositoryClassName); - + $repository = GeneralUtility::makeInstance($repositoryClassName); $repository->setDefaultQuerySettings($defaultQuerySettings); $this->filter[$filterName]['repository'] = $repositoryClassName; @@ -215,7 +242,7 @@ protected function buildFilter(): void 'propertyPath' => $filterProperties['propertyPath'], 'frontendLabel' => $this->buildFrontendLabel($filterProperties), 'disabledInFrontend' => $this->isFilterInFrontendVisible($filterProperties), - 'singleSelect' => $filterProperties['singleSelect'] + 'singleSelect' => $filterProperties['singleSelect'] ?? '' ]; switch ($filterProperties['type']) { diff --git a/Classes/Service/SlugService.php b/Classes/Service/SlugService.php index 07af0306..f3163db9 100644 --- a/Classes/Service/SlugService.php +++ b/Classes/Service/SlugService.php @@ -12,10 +12,7 @@ class SlugService { - protected ?QueryBuilder $queryBuilder = null; - protected array $fieldConfig = []; - protected ?SlugHelper $slugHelper = null; /** @@ -31,10 +28,6 @@ public function __construct() 'path_segment', $this->fieldConfig ); - - /** @var QueryBuilder $queryBuilder */ - $this->queryBuilder = - GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(StudyCourse::TABLE); } /** @@ -42,34 +35,36 @@ public function __construct() */ public function performUpdates(): array { - $this->queryBuilder->getRestrictions()->removeAll(); + $queryBuilder = + GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(StudyCourse::TABLE); + + $queryBuilder->getRestrictions()->removeAll(); $databaseQueries = []; - $statement = $this->queryBuilder->select('*') - ->from(StudyCourse::TABLE) + $records = $queryBuilder->select('*')->from(StudyCourse::TABLE) ->where( - $this->queryBuilder->expr()->orX( - $this->queryBuilder->expr()->eq( + $queryBuilder->expr()->or( + $queryBuilder->expr()->eq( 'url_segment', - $this->queryBuilder->createNamedParameter('', \PDO::PARAM_STR) + $queryBuilder->createNamedParameter('', \PDO::PARAM_STR) ), - $this->queryBuilder->expr()->isNull('url_segment') + $queryBuilder->expr()->isNull('url_segment') ) - ) - ->execute(); - while ($record = $statement->fetch()) { + )->executeQuery()->fetchAllAssociative(); + + foreach ($records as $record) { if ((string)$record['title'] !== '') { $slug = $this->slugHelper->generate($record, (int)$record['pid']); - $this->queryBuilder->update(StudyCourse::TABLE) + $queryBuilder->update(StudyCourse::TABLE) ->where( - $this->queryBuilder->expr()->eq( + $queryBuilder->expr()->eq( 'uid', - $this->queryBuilder->createNamedParameter($record['uid'], \PDO::PARAM_INT) + $queryBuilder->createNamedParameter($record['uid'], \PDO::PARAM_INT) ) ) ->set('url_segment', $slug); - $databaseQueries[] = $this->queryBuilder->getSQL(); - $this->queryBuilder->execute(); + $databaseQueries[] = $queryBuilder->getSQL(); + $queryBuilder->executeStatement(); } } @@ -81,20 +76,23 @@ public function performUpdates(): array */ public function isSlugUpdateRequired(): bool { - $this->queryBuilder->getRestrictions()->removeAll(); + $queryBuilder = + GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(StudyCourse::TABLE); + + $queryBuilder->getRestrictions()->removeAll(); - $count = $this->queryBuilder->count('uid') + $count = $queryBuilder->count('uid') ->from(StudyCourse::TABLE) ->where( - $this->queryBuilder->expr()->orX( - $this->queryBuilder->expr()->eq( + $queryBuilder->expr()->orX( + $queryBuilder->expr()->eq( 'url_segment', - $this->queryBuilder->createNamedParameter('') + $queryBuilder->createNamedParameter('') ), - $this->queryBuilder->expr()->isNull('url_segment') + $queryBuilder->expr()->isNull('url_segment') ) ) - ->execute()->fetchColumn(0); + ->executeQuery()->fetchOne(); return $count > 0; } diff --git a/Classes/Slug/UrlSegmentPostModifier.php b/Classes/Slug/UrlSegmentPostModifier.php index 5f493d5e..a44d06cd 100644 --- a/Classes/Slug/UrlSegmentPostModifier.php +++ b/Classes/Slug/UrlSegmentPostModifier.php @@ -8,17 +8,20 @@ use In2code\In2studyfinder\Domain\Model\Graduation; use In2code\In2studyfinder\Domain\Model\StudyCourse; use LogicException; +use Psr\Http\Message\ServerRequestInterface; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\DataHandling\SlugHelper; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\MathUtility; /** - * Class UrlSegmentPostModifier + * @SuppressWarnings(PHPMD.Superglobals) */ class UrlSegmentPostModifier { protected array $configuration = []; + protected int $courseId = -1; + protected int $academicDegree = -1; /** * @noinspection PhpUnusedParameterInspection @@ -28,17 +31,17 @@ class UrlSegmentPostModifier public function extendWithGraduation(array $configuration, SlugHelper $slugHelper): string { $this->configuration = $configuration; - $graduationTitle = ''; - if ($this->isNewRecord()) { - if (!empty($this->configuration['record']['academic_degree'])) { - $graduationTitle = - $this->getGraduationTitle((int)$this->configuration['record']['academic_degree']); - } - } else { - $graduationTitle = $this->getGraduationTitle(); + if (!$this->isUpgradeWizard() && !$this->isNewRecord()) { + $this->courseId = $this->getStudyCourseRecordIdentifier(); + } + + if (!empty($this->configuration['record']['academic_degree'])) { + $this->academicDegree = (int)$this->configuration['record']['academic_degree']; } + $graduationTitle = $this->getGraduationTitle(); + if (!empty($graduationTitle)) { $slug = $configuration['slug'] . '-' . $graduationTitle; } else { @@ -48,18 +51,15 @@ public function extendWithGraduation(array $configuration, SlugHelper $slugHelpe return $slug; } - /** - * @throws \Doctrine\DBAL\DBALException - */ - protected function getGraduationTitle(int $academicDegreeUid = -1): string + protected function getGraduationTitle(): string { $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(StudyCourse::TABLE); $queryBuilder->select(Graduation::TABLE . '.title'); - if ($academicDegreeUid > 0) { - $queryBuilder + if ($this->academicDegree > 0) { + return (string)$queryBuilder ->from(Graduation::TABLE) ->leftJoin( Graduation::TABLE, @@ -70,8 +70,14 @@ protected function getGraduationTitle(int $academicDegreeUid = -1): string AcademicDegree::TABLE . '.graduation' ) ) - ->where($queryBuilder->expr()->eq(AcademicDegree::TABLE . '.uid', $academicDegreeUid)); - } else { + ->where( + $queryBuilder->expr()->eq(AcademicDegree::TABLE . '.uid', + $this->academicDegree + ) + )->executeQuery()->fetchOne(); + } + + if ($this->courseId > 0) { $queryBuilder->from(StudyCourse::TABLE) ->leftJoin( StudyCourse::TABLE, @@ -92,45 +98,45 @@ protected function getGraduationTitle(int $academicDegreeUid = -1): string ) ) ->where( - $queryBuilder->expr()->eq(StudyCourse::TABLE . '.uid', $this->getStudyCourseRecordIdentifier()) + $queryBuilder->expr()->eq(StudyCourse::TABLE . '.uid', $this->courseId) ); + + return (string)$queryBuilder->executeQuery()->fetchOne(); } - return (string)$queryBuilder->execute()->fetchColumn(); + return ''; } - protected function isNewRecord(): bool + private function isUpgradeWizard(): bool { - if ($this->isRecalculateSlug()) { - $recordUid = GeneralUtility::_GP('recordId'); - - if (!MathUtility::canBeInterpretedAsInteger($recordUid)) { - return true; - } - } else { - $data = GeneralUtility::_GP('data'); - if (is_array($data) && key($data) === StudyCourse::TABLE) { - return true; - } - } + return !is_null(GeneralUtility::_GP('install')) && + array_key_exists('action', GeneralUtility::_GP('install')) && + GeneralUtility::_GP('install')['action'] === 'upgradeWizardsExecute'; + } - return false; + protected function isNewRecord(): bool + { + return $this->isRecalculateSlug() && + !MathUtility::canBeInterpretedAsInteger($this->getRequest()->getParsedBody()['recordId']); } protected function getStudyCourseRecordIdentifier(): int { - if (!empty($this->configuration['record']['uid'])) { - $identifier = $this->configuration['record']['uid']; - } elseif ((int)GeneralUtility::_GP('recordId') > 0) { - $identifier = (int)GeneralUtility::_GP('recordId'); - } else { + $identifier = $this->getRequest()->getParsedBody()['recordId']; + if (!MathUtility::canBeInterpretedAsInteger($identifier)) { throw new LogicException('No record identifier given', 1585056768); } - return $identifier; + + return (int)$identifier; } protected function isRecalculateSlug(): bool { - return GeneralUtility::_GP('route') === '/ajax/record/slug/suggest'; + return $this->getRequest()->getAttribute('route')->getPath() === '/ajax/record/slug/suggest'; + } + + private function getRequest(): ServerRequestInterface + { + return $GLOBALS['TYPO3_REQUEST']; } } diff --git a/Classes/Utility/PageUtility.php b/Classes/Utility/PageUtility.php index 0ffec7e6..1df45f91 100644 --- a/Classes/Utility/PageUtility.php +++ b/Classes/Utility/PageUtility.php @@ -40,13 +40,13 @@ public function getTreeList(int $uid, int $depth, int $begin = 0, string $permCl if ($permClause !== '') { $queryBuilder->andWhere(QueryHelper::stripLogicalOperatorPrefix($permClause)); } - $statement = $queryBuilder->execute(); - while ($row = $statement->fetchAssociative()) { + + foreach ($queryBuilder->executeQuery()->fetchAllAssociative() as $page) { if ($begin <= 0) { - $theList .= ',' . $row['uid']; + $theList .= ',' . $page['uid']; } if ($depth > 1) { - $theSubList = self::getTreeList($row['uid'], $depth - 1, $begin - 1, $permClause); + $theSubList = self::getTreeList((int)$page['uid'], $depth - 1, $begin - 1, $permClause); if (!empty($theList) && !empty($theSubList) && ($theSubList[0] !== ',')) { $theList .= ','; } diff --git a/Classes/Utility/RecordUtility.php b/Classes/Utility/RecordUtility.php index 0fe26474..eb71a9c0 100644 --- a/Classes/Utility/RecordUtility.php +++ b/Classes/Utility/RecordUtility.php @@ -51,7 +51,7 @@ public static function getRecordWithTranslations(int $uid): array 'l18n_parent', $queryBuilder->createNamedParameter((int)$records[0]['uid'], \PDO::PARAM_INT) ) - )->execute()->fetchAll(); + )->executeQuery()->fetchAllAssociative(); foreach ($translatedRecords as $translatedRecord) { if (!array_key_exists((int)$translatedRecord['sys_language_uid'], $records)) { @@ -139,7 +139,7 @@ public static function getRecord( $queryBuilder->andWhere(QueryHelper::stripLogicalOperatorPrefix($where)); } - $row = $queryBuilder->execute()->fetch(); + $row = $queryBuilder->executeQuery()->fetchAssociative(); if ($row) { return $row; } diff --git a/Classes/ViewHelpers/Form/AbstractCheckboxViewHelper.php b/Classes/ViewHelpers/Form/AbstractCheckboxViewHelper.php deleted file mode 100755 index 11898e57..00000000 --- a/Classes/ViewHelpers/Form/AbstractCheckboxViewHelper.php +++ /dev/null @@ -1,51 +0,0 @@ -registerArgument( - 'possibleFilters', - 'array', - 'Array which holds "propertyType" => array("uid", ...)" for each available filter option', - true, - [] - ); - $this->registerArgument( - 'searchedOptions', - 'array', - 'Array of the previously selected filter options', - true, - [] - ); - } - - protected function setDisabledIfNotAvailable(): void - { - [$propertyName, $objectId] = explode('_', $this->arguments['id']); - - if (is_array($this->arguments['possibleFilters']) && !empty($this->arguments['possibleFilters'])) { - if ( - !isset($this->arguments['possibleFilters'][$propertyName]) - || !in_array($objectId, $this->arguments['possibleFilters'][$propertyName]) - ) { - $this->tag->addAttribute('disabled', 'disabled'); - } - } - } - - protected function setSelectedIfPreviouslySelected(): void - { - [$propertyName, $objectId] = explode('_', $this->arguments['id']); - if (isset($this->arguments['searchedOptions'][$propertyName])) { - if (in_array($objectId, $this->arguments['searchedOptions'][$propertyName])) { - $this->tag->addAttribute('checked', true); - } - } - } -} diff --git a/Classes/ViewHelpers/Form/AbstractSelectViewHelper.php b/Classes/ViewHelpers/Form/AbstractSelectViewHelper.php new file mode 100644 index 00000000..9de13a84 --- /dev/null +++ b/Classes/ViewHelpers/Form/AbstractSelectViewHelper.php @@ -0,0 +1,275 @@ +registerUniversalTagAttributes(); + $this->registerTagAttribute('size', 'string', 'Size of input field'); + $this->registerTagAttribute('disabled', 'string', 'Specifies that the input element should be disabled when the page loads'); + $this->registerArgument('options', 'array', 'Associative array with internal IDs as key, and the values are displayed in the select box. Can be combined with or replaced by child f:form.select.* nodes.'); + $this->registerArgument('optionsAfterContent', 'boolean', 'If true, places auto-generated option tags after those rendered in the tag content. If false, automatic options come first.', false, false); + $this->registerArgument('optionValueField', 'string', 'If specified, will call the appropriate getter on each object to determine the value.'); + $this->registerArgument('optionLabelField', 'string', 'If specified, will call the appropriate getter on each object to determine the label.'); + $this->registerArgument('sortByOptionLabel', 'boolean', 'If true, List will be sorted by label.', false, false); + $this->registerArgument('selectAllByDefault', 'boolean', 'If specified options are selected if none was set before.', false, false); + $this->registerArgument('errorClass', 'string', 'CSS class to set if there are errors for this ViewHelper', false, 'f3-form-error'); + $this->registerArgument('prependOptionLabel', 'string', 'If specified, will provide an option at first position with the specified label.'); + $this->registerArgument('prependOptionValue', 'string', 'If specified, will provide an option at first position with the specified value.'); + $this->registerArgument('multiple', 'boolean', 'If set multiple options may be selected.', false, false); + $this->registerArgument('required', 'boolean', 'If set no empty value is allowed.', false, false); + } + + public function render(): string + { + if ($this->arguments['required']) { + $this->tag->addAttribute('required', 'required'); + } + $name = $this->getName(); + if ($this->arguments['multiple']) { + $this->tag->addAttribute('multiple', 'multiple'); + $name .= '[]'; + } + $this->tag->addAttribute('name', $name); + $options = $this->getOptions(); + + $viewHelperVariableContainer = $this->renderingContext->getViewHelperVariableContainer(); + + $this->addAdditionalIdentityPropertiesIfNeeded(); + $this->setErrorClassAttribute(); + $content = ''; + + // register field name for token generation. + $this->registerFieldNameForFormTokenGeneration($name); + // in case it is a multi-select, we need to register the field name + // as often as there are elements in the box + if ($this->arguments['multiple']) { + $content .= $this->renderHiddenFieldForEmptyValue(); + // Register the field name additional times as required by the total number of + // options. Since we already registered it once above, we start the counter at 1 + // instead of 0. + $optionsCount = count($options); + for ($i = 1; $i < $optionsCount; $i++) { + $this->registerFieldNameForFormTokenGeneration($name); + } + // save the parent field name so that any child f:form.select.option + // tag will know to call registerFieldNameForFormTokenGeneration + // this is the reason why "self::class" is used instead of static::class (no LSB) + $viewHelperVariableContainer->addOrUpdate( + self::class, + 'registerFieldNameForFormTokenGeneration', + $name + ); + } + + $viewHelperVariableContainer->addOrUpdate(self::class, 'selectedValue', $this->getSelectedValue()); + $prependContent = $this->renderPrependOptionTag(); + $tagContent = $this->renderOptionTags($options); + $childContent = $this->renderChildren(); + $viewHelperVariableContainer->remove(self::class, 'selectedValue'); + $viewHelperVariableContainer->remove(self::class, 'registerFieldNameForFormTokenGeneration'); + if (isset($this->arguments['optionsAfterContent']) && $this->arguments['optionsAfterContent']) { + $tagContent = $childContent . $tagContent; + } else { + $tagContent .= $childContent; + } + $tagContent = $prependContent . $tagContent; + + $this->tag->forceClosingTag(true); + $this->tag->setContent($tagContent); + $content .= $this->tag->render(); + return $content; + } + + /** + * Render prepended option tag + */ + protected function renderPrependOptionTag(): string + { + $output = ''; + if ($this->hasArgument('prependOptionLabel')) { + $value = $this->hasArgument('prependOptionValue') ? $this->arguments['prependOptionValue'] : ''; + $label = $this->arguments['prependOptionLabel']; + $output .= $this->renderOptionTag((string)$value, (string)$label, false) . LF; + } + return $output; + } + + /** + * Render the option tags. + */ + protected function renderOptionTags(array $options): string + { + $output = ''; + foreach ($options as $value => $label) { + $isSelected = $this->isSelected($value); + $output .= $this->renderOptionTag((string)$value, (string)$label, $isSelected) . LF; + } + return $output; + } + + /** + * Render the option tags. + * + * @return array An associative array of options, key will be the value of the option tag + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + protected function getOptions(): array + { + if (!is_array($this->arguments['options']) && !$this->arguments['options'] instanceof \Traversable) { + return []; + } + $options = []; + $optionsArgument = $this->arguments['options']; + foreach ($optionsArgument as $key => $value) { + if (is_object($value) || is_array($value)) { + if ($this->hasArgument('optionValueField')) { + $key = ObjectAccess::getPropertyPath($value, $this->arguments['optionValueField']); + if (is_object($key)) { + if (method_exists($key, '__toString')) { + $key = (string)$key; + } else { + throw new Exception('Identifying value for object of class "' . get_debug_type($value) . '" was an object.', 1247827428); + } + } + } elseif ($this->persistenceManager->getIdentifierByObject($value) !== null) { + // @todo use $this->persistenceManager->isNewObject() once it is implemented + $key = $this->persistenceManager->getIdentifierByObject($value); + } elseif (is_object($value) && method_exists($value, '__toString')) { + $key = (string)$value; + } elseif (is_object($value)) { + throw new Exception('No identifying value for object of class "' . get_class($value) . '" found.', 1247826696); + } + if ($this->hasArgument('optionLabelField')) { + $value = ObjectAccess::getPropertyPath($value, $this->arguments['optionLabelField']); + if (is_object($value)) { + if (method_exists($value, '__toString')) { + $value = (string)$value; + } else { + throw new Exception('Label value for object of class "' . get_class($value) . '" was an object without a __toString() method.', 1247827553); + } + } + } elseif (is_object($value) && method_exists($value, '__toString')) { + $value = (string)$value; + } elseif ($this->persistenceManager->getIdentifierByObject($value) !== null) { + // @todo use $this->persistenceManager->isNewObject() once it is implemented + $value = $this->persistenceManager->getIdentifierByObject($value); + } + } + $options[$key] = $value; + } + if ($this->arguments['sortByOptionLabel']) { + asort($options, SORT_LOCALE_STRING); + } + return $options; + } + + /** + * Render the option tags. + * + * @param mixed $value Value to check for + * @return bool True if the value should be marked as selected. + */ + protected function isSelected($value): bool + { + $selectedValue = $this->getSelectedValue(); + if ($value === $selectedValue || (string)$value === $selectedValue) { + return true; + } + if ($this->hasArgument('multiple')) { + if ($selectedValue === null && $this->arguments['selectAllByDefault'] === true) { + return true; + } + if (is_array($selectedValue) && in_array($value, $selectedValue)) { + return true; + } + } + return false; + } + + /** + * Retrieves the selected value(s) + * + * @return mixed value string or an array of strings + */ + protected function getSelectedValue() + { + $this->setRespectSubmittedDataValue(true); + $value = $this->getValueAttribute(); + if (!is_array($value) && !$value instanceof \Traversable) { + return $this->getOptionValueScalar($value); + } + $selectedValues = []; + foreach ($value as $selectedValueElement) { + $selectedValues[] = $this->getOptionValueScalar($selectedValueElement); + } + return $selectedValues; + } + + /** + * Get the option value for an object + * + * @param mixed $valueElement + * @return string @todo: Does not always return string ... + */ + protected function getOptionValueScalar($valueElement) + { + if (is_object($valueElement)) { + if ($this->hasArgument('optionValueField')) { + return ObjectAccess::getPropertyPath($valueElement, $this->arguments['optionValueField']); + } + // @todo use $this->persistenceManager->isNewObject() once it is implemented + if ($this->persistenceManager->getIdentifierByObject($valueElement) !== null) { + return $this->persistenceManager->getIdentifierByObject($valueElement); + } + return (string)$valueElement; + } + return $valueElement; + } + + /** + * Render one option tag + * + * @param string $value value attribute of the option tag (will be escaped) + * @param string $label content of the option tag (will be escaped) + * @param bool $isSelected specifies whether to add selected attribute + * @return string the rendered option tag + */ + protected function renderOptionTag(string $value, string $label, bool $isSelected): string + { + $output = '