diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..e5b707b --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,150 @@ +name: CI +on: + - pull_request +jobs: + check-composer: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Install Composer + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + coverage: none + tools: composer:v2 + env: + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Validate composer.json + run: composer validate + + php-linting: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: + - 7.4 + - 8.0 + - 8.1 + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: PHP lint + run: "find *.php Classes Configuration Tests -name '*.php' -print0 | xargs -0 -n 1 -P 4 php -l" + + xml-linting: + runs-on: ubuntu-latest + needs: [check-composer] + steps: + - uses: actions/checkout@v2 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.1" + coverage: none + tools: composer:v2 + + - name: Install xmllint + run: sudo apt-get install libxml2-utils + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-suggest + + - name: PHPUnit configuration file + run: xmllint --schema vendor/phpunit/phpunit/phpunit.xsd --noout phpunit.xml.dist + + - name: Fetch schema for xliff + run: wget https://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd --output-document=xliff-core-1.2-strict.xsd + + - name: TYPO3 language files + run: xmllint --schema xliff-core-1.2-strict.xsd --noout $(find Resources -name '*.xlf') + + coding-guideline: + runs-on: ubuntu-latest + needs: + - php-linting + - xml-linting + steps: + - uses: actions/checkout@v2 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.1" + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-suggest + + - name: Coding Guideline + run: ./vendor/bin/php-cs-fixer fix --dry-run --diff + + code-quality: + runs-on: ubuntu-latest + needs: + - php-linting + strategy: + matrix: + include: + - php-version: '7.4' + - php-version: '8.0' + - php-version: '8.1' + steps: + - uses: actions/checkout@v2 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "${{ matrix.php-version }}" + coverage: none + tools: composer:v2 + + - name: Install dependencies with expected TYPO3 version + run: composer require --no-interaction --prefer-dist --no-progress + + - name: Code Quality (by PHPStan) + run: ./vendor/bin/phpstan analyse + + tests-mysql: + runs-on: ubuntu-latest + needs: + - php-linting + - xml-linting + strategy: + matrix: + include: + - php-version: '7.4' + - php-version: '8.0' + - php-version: '8.1' + steps: + - uses: actions/checkout@v2 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "${{ matrix.php-version }}" + coverage: none + tools: composer:v2 + + - name: Setup MySQL + uses: mirromutth/mysql-action@v1.1 + with: + mysql version: '8' + mysql database: 'typo3' + mysql root password: 'root' + + - name: Install dependencies + run: composer require --no-interaction --prefer-dist --no-progress + + - name: PHPUnit Tests + run: |- + export typo3DatabaseDriver="pdo_mysql" + export typo3DatabaseName="typo3" + export typo3DatabaseHost="127.0.0.1" + export typo3DatabaseUsername="root" + export typo3DatabasePassword="root" + ./vendor/bin/phpunit --testdox diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb00166 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/composer.lock +/.php-cs-fixer.cache +/vendor/ +/public/ +/var/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..80e0228 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,62 @@ +ignoreVCSIgnored(true) + ->in(realpath(__DIR__)); + +return (new \PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@DoctrineAnnotation' => true, + '@PSR2' => true, + 'array_syntax' => ['syntax' => 'short'], + 'blank_line_after_opening_tag' => true, + 'braces' => ['allow_single_line_closure' => true], + 'cast_spaces' => ['space' => 'none'], + 'compact_nullable_typehint' => true, + 'concat_space' => ['spacing' => 'one'], + 'declare_equal_normalize' => ['space' => 'none'], + 'dir_constant' => true, + 'function_to_constant' => ['functions' => ['get_called_class', 'get_class', 'get_class_this', 'php_sapi_name', 'phpversion', 'pi']], + 'function_typehint_space' => true, + 'lowercase_cast' => true, + 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], + 'modernize_strpos' => true, + 'modernize_types_casting' => true, + 'native_function_casing' => true, + 'new_with_braces' => true, + 'no_alias_functions' => true, + 'no_blank_lines_after_phpdoc' => true, + 'no_empty_phpdoc' => true, + 'no_empty_statement' => true, + 'no_extra_blank_lines' => true, + 'no_leading_import_slash' => true, + 'no_leading_namespace_whitespace' => true, + 'no_null_property_initialization' => true, + 'no_short_bool_cast' => true, + 'no_singleline_whitespace_before_semicolons' => true, + 'no_superfluous_elseif' => true, + 'no_trailing_comma_in_singleline_array' => true, + 'no_unneeded_control_parentheses' => true, + 'no_unused_imports' => true, + 'no_useless_else' => true, + 'no_whitespace_in_blank_line' => true, + 'ordered_imports' => true, + 'php_unit_construct' => ['assertions' => ['assertEquals', 'assertSame', 'assertNotEquals', 'assertNotSame']], + 'php_unit_mock_short_will_return' => true, + 'php_unit_test_case_static_method_calls' => ['call_type' => 'self'], + 'phpdoc_no_access' => true, + 'phpdoc_no_empty_return' => true, + 'phpdoc_no_package' => true, + 'phpdoc_scalar' => true, + 'phpdoc_trim' => true, + 'phpdoc_types' => true, + 'phpdoc_types_order' => ['null_adjustment' => 'always_last', 'sort_algorithm' => 'none'], + 'return_type_declaration' => ['space_before' => 'none'], + 'single_quote' => true, + 'single_line_comment_style' => ['comment_types' => ['hash']], + 'single_trait_insert_per_statement' => true, + 'trailing_comma_in_multiline' => ['elements' => ['arrays']], + 'whitespace_after_comma_in_array' => true, + 'yoda_style' => ['equal' => false, 'identical' => false, 'less_and_greater' => false], + ]) + ->setFinder($finder); diff --git a/Classes/Controller/WatchlistController.php b/Classes/Controller/WatchlistController.php new file mode 100644 index 0000000..abdf508 --- /dev/null +++ b/Classes/Controller/WatchlistController.php @@ -0,0 +1,84 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\Watchlist\Controller; + +use Psr\Http\Message\ResponseInterface; +use TYPO3\CMS\Core\Http\PropagateResponseException; +use TYPO3\CMS\Core\Http\RedirectResponse; +use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; +use WerkraumMedia\Watchlist\Domain\Model\Item; +use WerkraumMedia\Watchlist\Domain\Model\Watchlist; +use WerkraumMedia\Watchlist\Domain\Repository\WatchlistRepository; + +class WatchlistController extends ActionController +{ + private WatchlistRepository $repository; + + public function __construct( + WatchlistRepository $repository + ) { + $this->repository = $repository; + } + + protected function initializeAction(): void + { + if ($this->request->hasArgument('watchlist') === false) { + $this->request->setArgument('watchlist', 'default'); + } + } + + public function indexAction( + Watchlist $watchlist + ): ResponseInterface { + $this->view->assignMultiple([ + 'watchlist' => $watchlist, + ]); + + return $this->htmlResponse(); + } + + public function addAction( + Watchlist $watchlist, + Item $item, + string $redirectUri = '' + ): void { + $watchlist->addItem($item); + + $this->repository->update($watchlist); + + throw new PropagateResponseException(new RedirectResponse($redirectUri, 303), 1664189968); + } + + public function removeAction( + Watchlist $watchlist, + Item $item, + string $redirectUri + ): void { + $watchlist->removeItem($item); + + $this->repository->update($watchlist); + + throw new PropagateResponseException(new RedirectResponse($redirectUri, 303), 1664189969); + } +} diff --git a/Classes/Domain/ItemHandlerInterface.php b/Classes/Domain/ItemHandlerInterface.php new file mode 100644 index 0000000..db14b86 --- /dev/null +++ b/Classes/Domain/ItemHandlerInterface.php @@ -0,0 +1,45 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\Watchlist\Domain; + +use WerkraumMedia\Watchlist\Domain\Model\Item; + +/** + * Defines interface of concrete handler implementations per item type. + * + * Each item has a type, and each type has an handler. + * The handler needs to take care of creating the item instances. + */ +interface ItemHandlerInterface +{ + /** + * Returns an item instance based on its identifier. + */ + public function return(string $identifier): ?Item; + + /** + * Returns the type the handler can handle, e.g. "pages" or "tt_address". + */ + public function handlesType(): string; +} diff --git a/Classes/Domain/ItemHandlerRegistry.php b/Classes/Domain/ItemHandlerRegistry.php new file mode 100644 index 0000000..11c2d33 --- /dev/null +++ b/Classes/Domain/ItemHandlerRegistry.php @@ -0,0 +1,55 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\Watchlist\Domain; + +class ItemHandlerRegistry +{ + /** + * @var ItemHandlerInterface[] + */ + private array $itemHandler = []; + + public function exists(string $type): bool + { + return isset($this->itemHandler[$type]); + } + + public function get(string $type): ItemHandlerInterface + { + return $this->itemHandler[$type]; + } + + public function add(ItemHandlerInterface $itemHandler): void + { + $this->itemHandler[$itemHandler->handlesType()] = $itemHandler; + } + + /** + * @return string[] + */ + public function getSupportedTypes(): array + { + return array_keys($this->itemHandler); + } +} diff --git a/Classes/Domain/Items/Page/ItemHandler.php b/Classes/Domain/Items/Page/ItemHandler.php new file mode 100644 index 0000000..83e2f68 --- /dev/null +++ b/Classes/Domain/Items/Page/ItemHandler.php @@ -0,0 +1,65 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\Watchlist\Domain\Items\Page; + +use TYPO3\CMS\Core\Database\ConnectionPool; +use WerkraumMedia\Watchlist\Domain\ItemHandlerInterface; +use WerkraumMedia\Watchlist\Domain\Model\Item; + +class ItemHandler implements ItemHandlerInterface +{ + private ConnectionPool $connectionPool; + + public function __construct( + ConnectionPool $connectionPool + ) { + $this->connectionPool = $connectionPool; + } + + public function return(string $identifier): ?Item + { + $pageUid = (int)$identifier; + $pageRecord = $this->getPageRecord($pageUid); + + return new Page( + $pageUid, + (string)$pageRecord['title'] + ); + } + + public function handlesType(): string + { + return 'page'; + } + + private function getPageRecord(int $uid): array + { + $qb = $this->connectionPool->getQueryBuilderForTable('pages'); + $qb->select('title'); + $qb->from('pages'); + $qb->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid))); + $qb->setMaxResults(1); + return $qb->execute()->fetchAssociative() ?: []; + } +} diff --git a/Classes/Domain/Items/Page/Page.php b/Classes/Domain/Items/Page/Page.php new file mode 100644 index 0000000..b52b0a6 --- /dev/null +++ b/Classes/Domain/Items/Page/Page.php @@ -0,0 +1,51 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\Watchlist\Domain\Items\Page; + +use WerkraumMedia\Watchlist\Domain\Model\Item; + +class Page implements Item +{ + private int $pageUid; + + private string $title; + + public function __construct( + int $pageUid, + string $title + ) { + $this->pageUid = $pageUid; + $this->title = $title; + } + + public function getUniqueIdentifier(): string + { + return 'page-' . $this->pageUid; + } + + public function getTitle(): string + { + return $this->title; + } +} diff --git a/Classes/Domain/Model/Item.php b/Classes/Domain/Model/Item.php new file mode 100644 index 0000000..adf0b0c --- /dev/null +++ b/Classes/Domain/Model/Item.php @@ -0,0 +1,32 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\Watchlist\Domain\Model; + +interface Item +{ + /** + * Needs to uniquely identify the thing throughout the system. + */ + public function getUniqueIdentifier(): string; +} diff --git a/Classes/Domain/Model/Watchlist.php b/Classes/Domain/Model/Watchlist.php new file mode 100644 index 0000000..93618d5 --- /dev/null +++ b/Classes/Domain/Model/Watchlist.php @@ -0,0 +1,60 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\Watchlist\Domain\Model; + +class Watchlist +{ + /** + * @var Item[] + */ + private array $items = []; + + public function getIdentifier(): string + { + return 'default'; + } + + /** + * @return Item[] + */ + public function getItems(): array + { + return array_values($this->items); + } + + public function addItem(Item $item): void + { + $this->items[$item->getUniqueIdentifier()] = $item; + } + + public function removeItem(Item $item): void + { + unset($this->items[$item->getUniqueIdentifier()]); + } + + public function isEmpty(): bool + { + return $this->items === []; + } +} diff --git a/Classes/Domain/Repository/ItemRepository.php b/Classes/Domain/Repository/ItemRepository.php new file mode 100644 index 0000000..f637476 --- /dev/null +++ b/Classes/Domain/Repository/ItemRepository.php @@ -0,0 +1,56 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\Watchlist\Domain\Repository; + +use WerkraumMedia\Watchlist\Domain\ItemHandlerRegistry; +use WerkraumMedia\Watchlist\Domain\Model\Item; + +class ItemRepository +{ + private ItemHandlerRegistry $registry; + + public function __construct( + ItemHandlerRegistry $registry + ) { + $this->registry = $registry; + } + + public function getByUniqueIdentifier(string $uniqueIdentifier): ?Item + { + [$type, $identifier] = explode('-', $uniqueIdentifier, 2); + + if ($this->registry->exists($type)) { + return $this->registry->get($type)->return($identifier); + } + + throw new \Exception( + sprintf( + 'No item handler for type "%s", only supported: %s.', + $type, + implode(', ', $this->registry->getSupportedTypes()), + ), + 1664186139 + ); + } +} diff --git a/Classes/Domain/Repository/WatchlistRepository.php b/Classes/Domain/Repository/WatchlistRepository.php new file mode 100644 index 0000000..828086b --- /dev/null +++ b/Classes/Domain/Repository/WatchlistRepository.php @@ -0,0 +1,57 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\Watchlist\Domain\Repository; + +use WerkraumMedia\Watchlist\Domain\Model\Watchlist; +use WerkraumMedia\Watchlist\Session\SessionServiceInterface; + +class WatchlistRepository +{ + private SessionServiceInterface $sessionService; + + public function __construct( + SessionServiceInterface $sessionService + ) { + $this->sessionService = $sessionService; + } + + public function getByIdentifier(string $identifier): ?Watchlist + { + $watchlist = $this->sessionService->getWatchlist($identifier); + if ($watchlist instanceof Watchlist) { + return $watchlist; + } + + if ($identifier === 'default') { + return new Watchlist(); + } + + return null; + } + + public function update(Watchlist $watchlist): void + { + $this->sessionService->update($watchlist); + } +} diff --git a/Classes/Extbase/TypeConverter/ItemTypeConverter.php b/Classes/Extbase/TypeConverter/ItemTypeConverter.php new file mode 100644 index 0000000..4d2acdc --- /dev/null +++ b/Classes/Extbase/TypeConverter/ItemTypeConverter.php @@ -0,0 +1,59 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\Watchlist\Extbase\TypeConverter; + +use TYPO3\CMS\Extbase\Property\PropertyMappingConfigurationInterface; +use TYPO3\CMS\Extbase\Property\TypeConverter\AbstractTypeConverter; +use WerkraumMedia\Watchlist\Domain\Model\Item; +use WerkraumMedia\Watchlist\Domain\Repository\ItemRepository; + +class ItemTypeConverter extends AbstractTypeConverter +{ + protected $sourceTypes = ['string']; + + protected $targetType = Item::class; + + protected $priority = 10; + + private ItemRepository $itemRepository; + + public function __construct( + ItemRepository $itemRepository + ) { + $this->itemRepository = $itemRepository; + } + + public function convertFrom( + $source, + string $targetType, + array $convertedChildProperties = [], + PropertyMappingConfigurationInterface $configuration = null + ): ?Item { + if (is_string($source) === false) { + throw new \InvalidArgumentException('Source was not of expected type string.', 1664197305); + } + + return $this->itemRepository->getByUniqueIdentifier($source); + } +} diff --git a/Classes/Extbase/TypeConverter/WatchlistTypeConverter.php b/Classes/Extbase/TypeConverter/WatchlistTypeConverter.php new file mode 100644 index 0000000..2655648 --- /dev/null +++ b/Classes/Extbase/TypeConverter/WatchlistTypeConverter.php @@ -0,0 +1,58 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\Watchlist\Extbase\TypeConverter; + +use TYPO3\CMS\Extbase\Property\PropertyMappingConfigurationInterface; +use TYPO3\CMS\Extbase\Property\TypeConverter\AbstractTypeConverter; +use WerkraumMedia\Watchlist\Domain\Model\Watchlist; +use WerkraumMedia\Watchlist\Domain\Repository\WatchlistRepository; + +class WatchlistTypeConverter extends AbstractTypeConverter +{ + protected $sourceTypes = ['string']; + + protected $targetType = Watchlist::class; + + protected $priority = 10; + + private WatchlistRepository $repository; + + public function __construct( + WatchlistRepository $repository + ) { + $this->repository = $repository; + } + + public function convertFrom( + $source, + string $targetType, + array $convertedChildProperties = [], + PropertyMappingConfigurationInterface $configuration = null + ): ?Watchlist { + if (is_string($source) === false) { + throw new \InvalidArgumentException('Source was not of expected type string.', 1664197358); + } + return $this->repository->getByIdentifier($source); + } +} diff --git a/Classes/Session/SessionServiceInterface.php b/Classes/Session/SessionServiceInterface.php new file mode 100644 index 0000000..c8cd1e2 --- /dev/null +++ b/Classes/Session/SessionServiceInterface.php @@ -0,0 +1,33 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\Watchlist\Session; + +use WerkraumMedia\Watchlist\Domain\Model\Watchlist; + +interface SessionServiceInterface +{ + public function getWatchlist(string $identifier): ?Watchlist; + + public function update(Watchlist $watchlist): void; +} diff --git a/Classes/Session/Typo3FrontendSessionService.php b/Classes/Session/Typo3FrontendSessionService.php new file mode 100644 index 0000000..f67e114 --- /dev/null +++ b/Classes/Session/Typo3FrontendSessionService.php @@ -0,0 +1,73 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\Watchlist\Session; + +use TYPO3\CMS\Extbase\Property\PropertyMapper; +use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController; +use WerkraumMedia\Watchlist\Domain\Model\Item; +use WerkraumMedia\Watchlist\Domain\Model\Watchlist; + +class Typo3FrontendSessionService implements SessionServiceInterface +{ + private PropertyMapper $propertyMapper; + + public function __construct( + PropertyMapper $propertyMapper + ) { + $this->propertyMapper = $propertyMapper; + } + + public function getWatchlist(string $identifier): ?Watchlist + { + $items = $this->getTsfe()->fe_user->getSessionData('watchlist-' . $identifier) ?: []; + if ($items === [] || is_array($items) === false) { + return null; + } + + $watchlist = new Watchlist(); + + array_map(function (string $item) use ($watchlist) { + $mappedItem = $this->propertyMapper->convert($item, Item::class); + if (!$mappedItem instanceof Item) { + return; + } + + $watchlist->addItem($mappedItem); + }, $items); + + return $watchlist; + } + + public function update(Watchlist $watchlist): void + { + $this->getTsfe()->fe_user->setAndSaveSessionData( + 'watchlist-' . $watchlist->getIdentifier(), + array_map(fn (Item $item) => $item->getUniqueIdentifier(), $watchlist->getItems()) + ); + } + private function getTsfe(): TypoScriptFrontendController + { + return $GLOBALS['TSFE']; + } +} diff --git a/Configuration/Services.php b/Configuration/Services.php new file mode 100644 index 0000000..6f8f3de --- /dev/null +++ b/Configuration/Services.php @@ -0,0 +1,49 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\Watchlist; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use WerkraumMedia\Watchlist\Domain\ItemHandlerRegistry; + +return function (ContainerConfigurator $container, ContainerBuilder $containerBuilder) { + $containerBuilder->addCompilerPass(new class() implements CompilerPassInterface { + public function process(ContainerBuilder $container): void + { + $registry = $container->getDefinition(ItemHandlerRegistry::class) + ->setShared(true); + + foreach (array_keys($container->findTaggedServiceIds('watchlist.itemHandler')) as $id) { + $definition = $container->getDefinition($id); + + if (!$definition->isAutoconfigured() || $definition->isAbstract()) { + continue; + } + + $registry->addMethodCall('add', [$definition]); + } + } + }); +}; diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml new file mode 100644 index 0000000..4ad187a --- /dev/null +++ b/Configuration/Services.yaml @@ -0,0 +1,13 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + WerkraumMedia\Watchlist\: + resource: '../Classes/*' + exclude: + - '../Classes/Domain/Model/*' + + WerkraumMedia\Watchlist\Domain\Items\Page\ItemHandler: + tags: ['watchlist.itemHandler'] diff --git a/Configuration/TCA/Overrides/tt_content.php b/Configuration/TCA/Overrides/tt_content.php new file mode 100644 index 0000000..1172acc --- /dev/null +++ b/Configuration/TCA/Overrides/tt_content.php @@ -0,0 +1,14 @@ + + + +
+ + + Watchlist + + + Watchlist is empty + + + + diff --git a/Resources/Private/Templates/Watchlist/Index.html b/Resources/Private/Templates/Watchlist/Index.html new file mode 100644 index 0000000..1488416 --- /dev/null +++ b/Resources/Private/Templates/Watchlist/Index.html @@ -0,0 +1,18 @@ + + +

{f:translate(id: 'headline.index', extensionName: 'Watchlist')}

+ + + +

{f:translate(id: 'text.watchlist.isEmpty', extensionName: 'Watchlist')} + + +

    + +
  1. {item.title}
  2. +
    +
+ +
+ diff --git a/Tests/Functional/Frontend/BasicsTest.php b/Tests/Functional/Frontend/BasicsTest.php new file mode 100644 index 0000000..6757b71 --- /dev/null +++ b/Tests/Functional/Frontend/BasicsTest.php @@ -0,0 +1,144 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\Watchlist\Tests\Functional\Frontend; + +use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest; +use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalResponse; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +/** + * @covers \WerkraumMedia\Watchlist\Frontend\Basics + */ +class BasicsTest extends FunctionalTestCase +{ + protected $coreExtensionsToLoad = [ + 'fluid_styled_content', + ]; + + protected $testExtensionsToLoad = [ + 'typo3conf/ext/watchlist', + ]; + + protected $pathsToProvideInTestInstance = [ + 'typo3conf/ext/watchlist/Tests/Functional/Frontend/Fixtures/Sites/' => 'typo3conf/sites/', + ]; + + protected function setUp(): void + { + parent::setUp(); + + $this->setUpBackendUserFromFixture(1); + + $this->importCSVDataSet(__DIR__ . '/Fixtures/BasicDatabase.csv'); + $this->setUpFrontendRootPage(1, [ + 'EXT:watchlist/Tests/Functional/Frontend/Fixtures/FrontendRendering.typoscript', + 'EXT:fluid_styled_content/Configuration/TypoScript/setup.typoscript', + ]); + } + + /** + * @test + */ + public function watchlistIsRenderedAsEmptyByDefault(): void + { + $request = new InternalRequest(); + $request = $request->withPageId(1); + $result = $this->executeFrontendRequest($request); + + self::assertSame(200, $result->getStatusCode()); + self::assertStringContainsString('Watchlist is empty', $result->getBody()->__toString()); + } + + /** + * @test + */ + public function canStorePagesOnWatchlistAccrossPageCalls(): void + { + $request = new InternalRequest(); + $request = $request->withPageId(1); + $request = $request->withQueryParameter('tx_watchlist_watchlist[redirectUri]', $request->getUri()->__toString()); + $request = $request->withQueryParameter('tx_watchlist_watchlist[action]', 'add'); + $request = $request->withQueryParameter('tx_watchlist_watchlist[item]', 'page-1'); + $result = $this->executeFrontendRequest($request); + + self::assertIsRedirect('http://localhost/?id=1', $result); + self::assertHasCookie('fe_typo_user', $result); + + $request = new InternalRequest(); + $request = $request->withPageId(1); + $request = $request->withHeader('Cookie', self::getCookies($result)); + $result = $this->executeFrontendRequest($request); + + self::assertStringContainsString('Page Title', $result->getBody()->__toString()); + } + + /** + * @test + */ + public function canRemoveStoredEntryFromWatchlist(): void + { + $request = new InternalRequest(); + $request = $request->withPageId(1); + $request = $request->withQueryParameter('tx_watchlist_watchlist[redirectUri]', $request->getUri()->__toString()); + $request = $request->withQueryParameter('tx_watchlist_watchlist[action]', 'add'); + $request = $request->withQueryParameter('tx_watchlist_watchlist[item]', 'page-1'); + $result = $this->executeFrontendRequest($request); + + $cookies = self::getCookies($result); + + $request = new InternalRequest(); + $request = $request->withHeader('Cookie', $cookies); + $request = $request->withPageId(1); + $request = $request->withQueryParameter('tx_watchlist_watchlist[redirectUri]', $request->getUri()->__toString()); + $request = $request->withQueryParameter('tx_watchlist_watchlist[action]', 'remove'); + $request = $request->withQueryParameter('tx_watchlist_watchlist[item]', 'page-1'); + $result = $this->executeFrontendRequest($request); + + self::assertIsRedirect('http://localhost/?id=1', $result); + + $request = new InternalRequest(); + $request = $request->withHeader('Cookie', $cookies); + $request = $request->withPageId(1); + $result = $this->executeFrontendRequest($request); + + self::assertSame(200, $result->getStatusCode()); + self::assertStringContainsString('Watchlist is empty', $result->getBody()->__toString()); + } + + private static function assertIsRedirect(string $redirectLocation, InternalResponse $result): void + { + self::assertSame(303, $result->getStatusCode()); + self::assertSame($redirectLocation, $result->getHeader('location')[0] ?? ''); + } + + private static function assertHasCookie(string $cookieName, InternalResponse $result): void + { + self::assertStringContainsString($cookieName . '=', self::getCookies($result)); + } + + private static function getCookies(InternalResponse $result): string + { + return explode(' ', $result->getHeader('Set-Cookie')[0] ?? '', 2)[0] ?? ''; + } +} diff --git a/Tests/Functional/Frontend/Fixtures/BasicDatabase.csv b/Tests/Functional/Frontend/Fixtures/BasicDatabase.csv new file mode 100644 index 0000000..29aa1a9 --- /dev/null +++ b/Tests/Functional/Frontend/Fixtures/BasicDatabase.csv @@ -0,0 +1,3 @@ +pages +,uid,pid,slug,title +,1,0,/,Page Title diff --git a/Tests/Functional/Frontend/Fixtures/FrontendRendering.typoscript b/Tests/Functional/Frontend/Fixtures/FrontendRendering.typoscript new file mode 100644 index 0000000..c94ef7e --- /dev/null +++ b/Tests/Functional/Frontend/Fixtures/FrontendRendering.typoscript @@ -0,0 +1,2 @@ +page = PAGE +page.10 =< tt_content.watchlist_watchlist.20 diff --git a/Tests/Functional/Frontend/Fixtures/Sites/default/config.yaml b/Tests/Functional/Frontend/Fixtures/Sites/default/config.yaml new file mode 100644 index 0000000..55fde69 --- /dev/null +++ b/Tests/Functional/Frontend/Fixtures/Sites/default/config.yaml @@ -0,0 +1,32 @@ +base: / +languages: + - + title: Deutsch + enabled: true + base: / + typo3Language: de + locale: de_DE.UTF-8 + iso-639-1: de + navigationTitle: Deutsch + hreflang: de-DE + direction: '' + flag: de + websiteTitle: '' + languageId: 0 + - + title: English + enabled: true + base: /en + typo3Language: default + locale: en_GB.UTF-8 + iso-639-1: en + websiteTitle: '' + navigationTitle: English + hreflang: en-GB + direction: '' + flag: gb + languageId: 1 + fallbackType: strict + fallbacks: '0' +rootPageId: 1 +websiteTitle: 'Example Website' diff --git a/Tests/Unit/Domain/Model/WatchlistTest.php b/Tests/Unit/Domain/Model/WatchlistTest.php new file mode 100644 index 0000000..4d8f44f --- /dev/null +++ b/Tests/Unit/Domain/Model/WatchlistTest.php @@ -0,0 +1,87 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace WerkraumMedia\Watchlist\Tests\Unit\Domain\Model; + +use PHPUnit\Framework\TestCase; +use WerkraumMedia\Watchlist\Domain\Items\Page\Page; +use WerkraumMedia\Watchlist\Domain\Model\Watchlist; + +/** + * @covers \WerkraumMedia\Watchlist\Domain\Model\Watchlist + * @testdox The Watchlist + */ +class WatchlistTest extends TestCase +{ + /** + * @test + */ + public function canBeCreated(): void + { + $subject = new Watchlist(); + + self::assertInstanceOf( + Watchlist::class, + $subject + ); + } + + /** + * @test + */ + public function doesNotHaveItemsByDefault(): void + { + $subject = new Watchlist(); + + self::assertCount(0, $subject->getItems()); + } + + /** + * @test + */ + public function canAddAItem(): void + { + $subject = new Watchlist(); + + $thing = new Page(1, 'test'); + $subject->addItem($thing); + + self::assertCount(1, $subject->getItems()); + self::assertSame($thing, $subject->getItems()[0]); + } + + /** + * @test + */ + public function canRemoveAItem(): void + { + $subject = new Watchlist(); + + $thing = new Page(1, 'test'); + $subject->addItem($thing); + + $subject->removeItem($thing); + + self::assertCount(0, $subject->getItems()); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..9b9ff76 --- /dev/null +++ b/composer.json @@ -0,0 +1,80 @@ +{ + "name": "werkraummedia/watchlist", + "description": "Add a watchlist to frontend of TYPO3", + "type": "typo3-cms-extension", + "license": "GPL-2.0-or-later", + "homepage": "https://github.com/werkraum-media/watchlist", + "support": { + "docs": "https://docs.typo3.org/p/werkraummedia/watchlist/master/en-us/", + "email": "coding@daniel-siepmann.de", + "issues": "https://github.com/werkraum-media/watchlist/issues", + "source": "https://github.com/werkraum-media/watchlist" + }, + "authors": [ + { + "name": "Daniel Siepmann", + "email": "coding@daniel-siepmann.de", + "homepage": "https://daniel-siepmann.de/", + "role": "Developer" + } + ], + "autoload": { + "psr-4": { + "WerkraumMedia\\Watchlist\\": "Classes/" + } + }, + "autoload-dev": { + "psr-4": { + "WerkraumMedia\\Watchlist\\Tests\\": "Tests/" + } + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0", + "typo3/cms-backend": "^11.5", + "typo3/cms-core": "^11.5", + "typo3/cms-extbase": "^11.5", + "typo3/cms-frontend": "^11.5" + }, + "require-dev": { + "cweagans/composer-patches": "^1.7", + "friendsofphp/php-cs-fixer": "^3.11", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "1.1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^9.5", + "saschaegerer/phpstan-typo3": "^1.1", + "typo3/cms-fluid-styled-content": "^11.5", + "typo3/cms-tstemplate": "^11.5", + "typo3/testing-framework": "^6.6" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "typo3/cms-composer-installers": true, + "typo3/class-alias-loader": true, + "ocramius/package-versions": true, + "phpstan/extension-installer": true, + "cweagans/composer-patches": true + } + }, + "extra": { + "typo3/cms": { + "cms-package-dir": "{$vendor-dir}/typo3/cms", + "extension-key": "watchlist", + "web-dir": "public" + } + }, + "scripts": { + "post-autoload-dump": [ + "@php -r 'is_dir($extFolder=__DIR__.\"/public/typo3conf/ext/\") || mkdir($extFolder, 0777, true);'", + "@php -r 'file_exists($extFolder=__DIR__.\"/public/typo3conf/ext/watchlist\") || symlink(__DIR__,$extFolder);'" + ] + }, + "extra": { + "patches": { + "typo3/testing-framework": { + "Support cookies in request": "patches/testing-framework-cookies.patch" + } + } + } +} diff --git a/ext_localconf.php b/ext_localconf.php new file mode 100644 index 0000000..6b1dfa1 --- /dev/null +++ b/ext_localconf.php @@ -0,0 +1,25 @@ + 'index, add, remove', + ], + [ + WatchlistController::class => 'index', + ], + ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT + ); + + ExtensionUtility::registerTypeConverter(WatchlistTypeConverter::class); + ExtensionUtility::registerTypeConverter(ItemTypeConverter::class); +})(); diff --git a/patches/testing-framework-cookies.patch b/patches/testing-framework-cookies.patch new file mode 100644 index 0000000..152372f --- /dev/null +++ b/patches/testing-framework-cookies.patch @@ -0,0 +1,17 @@ +diff --git a/Classes/Core/Functional/Framework/Frontend/RequestBootstrap.php b/Classes/Core/Functional/Framework/Frontend/RequestBootstrap.php +index f8b2a40..4f4020d 100644 +--- a/Classes/Core/Functional/Framework/Frontend/RequestBootstrap.php ++++ b/Classes/Core/Functional/Framework/Frontend/RequestBootstrap.php +@@ -106,6 +106,12 @@ class RequestBootstrap + } + // Populating $_COOKIE + $_COOKIE = []; ++ if ($this->request->hasHeader('Cookie')) { ++ foreach ($this->request->getHeader('Cookie') as $cookie) { ++ [$cookieName, $cookieValue] = explode('=', $cookie, 2); ++ $_COOKIE[$cookieName] = rtrim($cookieValue, ';'); ++ } ++ } + + // Setting up the server environment + $_SERVER = []; diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..7115c54 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,12 @@ +parameters: + level: max + paths: + - Classes + - Configuration + - Tests + checkMissingIterableValueType: false + reportUnmatchedIgnoredErrors: true + checkGenericClassInNonGenericObjectType: false + ignoreErrors: + - "#^Cannot call method fetchAssociative\\(\\) on Doctrine\\\\DBAL\\\\Result\\|int\\.$#" + - "#^Right side of \\|\\| is always false\\.$#" diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..0acc317 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,38 @@ + + + + + + Classes + + + + + + Tests/Unit/ + + + Tests/Functional/ + + + + + + +