diff --git a/Classes/Constants.php b/Classes/Constants.php index 0d3762ff..4a665b2e 100644 --- a/Classes/Constants.php +++ b/Classes/Constants.php @@ -66,5 +66,6 @@ class Constants 'blog_commentswidget' => -1600000017, 'blog_archivewidget' => -1600000018, 'blog_feedwidget' => -1600000019, + 'blog_filter' => -1600000020, ]; } diff --git a/Classes/Controller/PostController.php b/Classes/Controller/PostController.php index 24b568cf..a1cc7ec7 100644 --- a/Classes/Controller/PostController.php +++ b/Classes/Controller/PostController.php @@ -12,6 +12,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use T3G\AgencyPack\Blog\Domain\Factory\PostFilterFactory; use T3G\AgencyPack\Blog\Domain\Model\Author; use T3G\AgencyPack\Blog\Domain\Model\Category; use T3G\AgencyPack\Blog\Domain\Model\Post; @@ -27,6 +28,7 @@ use T3G\AgencyPack\Blog\Utility\ArchiveUtility; use TYPO3\CMS\Core\Http\NormalizedParams; use TYPO3\CMS\Core\Site\Entity\SiteLanguage; +use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; use TYPO3\CMS\Extbase\Pagination\QueryResultPaginator; use TYPO3\CMS\Extbase\Persistence\QueryResultInterface; @@ -251,6 +253,29 @@ public function listPostsByTagAction(?Tag $tag = null, int $currentPage = 1): Re return $this->htmlResponse(); } + /** + * Show a list of posts by given filter. + */ + public function listPostsByFilterAction(int $currentPage = 1): ResponseInterface + { + $factory = GeneralUtility::makeInstance(PostFilterFactory::class, $this->categoryRepository, $this->tagRepository); + $filter = $factory->getFilterFromRequest($this->request); + $posts = $this->postRepository->findAllByFilter($filter); + $pagination = $this->getPagination($posts, $currentPage); + + $this->view->assign('type', 'byfilter'); + $this->view->assign('posts', $posts); + $this->view->assign('pagination', $pagination); + $this->view->assign('filter', $filter); + $this->view->assign('categories', $this->categoryRepository->findAll()); + $this->view->assign('tags', $this->tagRepository->findAll()); + + MetaTagService::set(MetaTagService::META_TITLE, $filter->getTitle()); + MetaTagService::set(MetaTagService::META_DESCRIPTION, $filter->getDescription()); + + return $this->htmlResponse(); + } + /** * Sidebar action. */ diff --git a/Classes/Domain/Factory/PostFilterFactory.php b/Classes/Domain/Factory/PostFilterFactory.php new file mode 100644 index 00000000..52ec13c7 --- /dev/null +++ b/Classes/Domain/Factory/PostFilterFactory.php @@ -0,0 +1,47 @@ +categoryRepository = $categoryRepository; + $this->tagRepository = $tagRepository; + } + + public function getFilterFromRequest(RequestInterface $request): PostFilter + { + // Currently there is only one filter implementation. + $filter = new CategoryTag(); + if ($request->hasArgument('category')) { + $filter->setCategory($this->categoryRepository->findByUid((int)$request->getArgument('category'))); + } + if ($request->hasArgument('tag')) { + $filter->setTag($this->tagRepository->findByUid((int)$request->getArgument('tag'))); + } + return $filter; + } +} diff --git a/Classes/Domain/Filter/Post/AbstractBase.php b/Classes/Domain/Filter/Post/AbstractBase.php new file mode 100644 index 00000000..38523ac4 --- /dev/null +++ b/Classes/Domain/Filter/Post/AbstractBase.php @@ -0,0 +1,29 @@ + 0 ? ++$rightBackslash : $rightBackslash); + } +} diff --git a/Classes/Domain/Filter/Post/CategoryTag.php b/Classes/Domain/Filter/Post/CategoryTag.php new file mode 100644 index 00000000..976b4598 --- /dev/null +++ b/Classes/Domain/Filter/Post/CategoryTag.php @@ -0,0 +1,99 @@ +category) { + if ($this->tag) { + return trim($this->category->getTitle() . ' ' . $this->tag->getTitle()); + } else { + return $this->category->getTitle(); + } + } + if ($this->tag) { + return $this->tag->getTitle(); + } + return ''; + } + + /** + * Description is simply the concatenated Category and Tag titles (separated by a SPACE character). + */ + public function getDescription(): string + { + if ($this->category) { + if ($this->tag) { + return trim($this->category->getDescription() . ' ' . $this->tag->getDescription()); + } else { + return $this->category->getDescription(); + } + } + if ($this->tag) { + return $this->tag->getDescription(); + } + return ''; + } + + /** + * @return array + * @throws InvalidQueryException + */ + public function getConstraints(QueryInterface $query): array + { + $constraints = []; + if ($this->category) { + $constraints[] = $query->contains('categories', $this->category); + } + if ($this->tag) { + $constraints[] = $query->contains('tags', $this->tag); + } + return $constraints; + } + + public function getCategory(): ?Category + { + return $this->category; + } + + public function setCategory(?Category $category): void + { + $this->category = $category; + } + + public function getTag(): ?Tag + { + return $this->tag; + } + + public function setTag(?Tag $tag): void + { + $this->tag = $tag; + } +} diff --git a/Classes/Domain/Filter/PostFilter.php b/Classes/Domain/Filter/PostFilter.php new file mode 100644 index 00000000..1e04c4ba --- /dev/null +++ b/Classes/Domain/Filter/PostFilter.php @@ -0,0 +1,42 @@ + + */ + public function getConstraints(QueryInterface $query): array; +} diff --git a/Classes/Domain/Repository/PostRepository.php b/Classes/Domain/Repository/PostRepository.php index 84fab51c..16261d00 100644 --- a/Classes/Domain/Repository/PostRepository.php +++ b/Classes/Domain/Repository/PostRepository.php @@ -13,6 +13,7 @@ use Psr\Http\Message\ServerRequestInterface; use T3G\AgencyPack\Blog\Constants; use T3G\AgencyPack\Blog\DataTransferObject\PostRepositoryDemand; +use T3G\AgencyPack\Blog\Domain\Filter\PostFilter; use T3G\AgencyPack\Blog\Domain\Model\Author; use T3G\AgencyPack\Blog\Domain\Model\Category; use T3G\AgencyPack\Blog\Domain\Model\Post; @@ -226,6 +227,18 @@ public function findAllByTag(Tag $tag): QueryResultInterface return $query->matching($query->logicalAnd(...$constraints))->execute(); } + public function findAllByFilter(PostFilter $filter): QueryResultInterface + { + $query = $this->createQuery(); + $constraints = array_merge($this->defaultConstraints, $filter->getConstraints($query)); + $storagePidConstraint = $this->getStoragePidConstraint(); + if ($storagePidConstraint instanceof ComparisonInterface) { + $constraints[] = $storagePidConstraint; + } + + return $query->matching($query->logicalAnd(...$constraints))->execute(); + } + public function findByMonthAndYear(int $year, int $month = null): QueryResultInterface { $query = $this->createQuery(); diff --git a/Configuration/TCA/Overrides/tt_content.php b/Configuration/TCA/Overrides/tt_content.php index c16ce498..f141b6ec 100644 --- a/Configuration/TCA/Overrides/tt_content.php +++ b/Configuration/TCA/Overrides/tt_content.php @@ -57,6 +57,14 @@ ); $GLOBALS['TCA']['tt_content']['types']['list']['subtypes_excludelist']['blog_archive'] = 'select_key'; +\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin( + 'Blog', + 'Filter', + 'LLL:EXT:blog/Resources/Private/Language/locallang_db.xlf:plugin.blog_filter.title', + 'plugin-blog-filter' +); +$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_excludelist']['blog_filter'] = 'select_key'; + \TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin( 'Blog', 'Sidebar', diff --git a/Configuration/TsConfig/Page/Wizards.tsconfig b/Configuration/TsConfig/Page/Wizards.tsconfig index 71555cd9..780b854d 100644 --- a/Configuration/TsConfig/Page/Wizards.tsconfig +++ b/Configuration/TsConfig/Page/Wizards.tsconfig @@ -59,6 +59,15 @@ mod.wizards.newContentElement.wizardItems.blog { list_type = blog_archive } } + blog_filter { + iconIdentifier = plugin-blog-filter + title = LLL:EXT:blog/Resources/Private/Language/locallang_db.xlf:plugin.blog_filter.title + description = LLL:EXT:blog/Resources/Private/Language/locallang_db.xlf:plugin.blog_filter.description + tt_content_defValues { + CType = list + list_type = blog_filter + } + } blog_demandedposts { iconIdentifier = plugin-blog-demandedposts title = LLL:EXT:blog/Resources/Private/Language/locallang_db.xlf:plugin.blog_demandedposts.title diff --git a/Configuration/TypoScript/Static/setup.typoscript b/Configuration/TypoScript/Static/setup.typoscript index f76db4b4..b19a72a2 100644 --- a/Configuration/TypoScript/Static/setup.typoscript +++ b/Configuration/TypoScript/Static/setup.typoscript @@ -440,3 +440,8 @@ blog_rss_author < blog_rss_posts blog_rss_author.typeNum = 250 blog_rss_author.10 < tt_content.list.20.blog_authorposts blog_rss_author.10.format = rss + +blog_rss_filter < blog_rss_posts +blog_rss_filter.typeNum = 260 +blog_rss_filter.10 < tt_content.list.20.blog_filter +blog_rss_filter.10.format = rss diff --git a/Resources/Private/Language/locallang.xlf b/Resources/Private/Language/locallang.xlf index 67c07b44..bc84c3ab 100644 --- a/Resources/Private/Language/locallang.xlf +++ b/Resources/Private/Language/locallang.xlf @@ -58,6 +58,9 @@ RSS-Feeds + + Filter + @@ -210,6 +213,14 @@ Show Archive + + + All categories + + + All tags + + SetupWizard diff --git a/Resources/Private/Language/locallang_db.xlf b/Resources/Private/Language/locallang_db.xlf index acebf728..703db03e 100644 --- a/Resources/Private/Language/locallang_db.xlf +++ b/Resources/Private/Language/locallang_db.xlf @@ -47,6 +47,12 @@ Allows the users to show all posts tagged with a specific keyword. + + Blog: List by filter + + + Allows the users to show a list of filtered posts. + Blog: Archive diff --git a/Resources/Private/Partials/Filter/Categories.html b/Resources/Private/Partials/Filter/Categories.html new file mode 100644 index 00000000..ed3886a1 --- /dev/null +++ b/Resources/Private/Partials/Filter/Categories.html @@ -0,0 +1,28 @@ + diff --git a/Resources/Private/Partials/Filter/Post/CategoryTag.html b/Resources/Private/Partials/Filter/Post/CategoryTag.html new file mode 100644 index 00000000..c93cca68 --- /dev/null +++ b/Resources/Private/Partials/Filter/Post/CategoryTag.html @@ -0,0 +1,21 @@ +
+

+ + + +

+ +

+ + + +

+ + +

+ + + +

+ +
diff --git a/Resources/Private/Partials/Filter/Tags.html b/Resources/Private/Partials/Filter/Tags.html new file mode 100644 index 00000000..28c5797a --- /dev/null +++ b/Resources/Private/Partials/Filter/Tags.html @@ -0,0 +1,28 @@ + diff --git a/Resources/Private/Scss/frontend/components/blog/_filter.scss b/Resources/Private/Scss/frontend/components/blog/_filter.scss new file mode 100644 index 00000000..7faa320f --- /dev/null +++ b/Resources/Private/Scss/frontend/components/blog/_filter.scss @@ -0,0 +1,27 @@ +/** + * Filter + */ + +.blogfilter__categories { + .blogfilter__category { + display: inline-block; + &.one { + margin: 1rem 0 1rem 1rem; + } + &.active { + font-weight: bold; + } + } +} + +.blogfilter__tags { + .blogfilter__tag { + display: inline-block; + &.one { + margin: 1rem 0 1rem 1rem; + } + &.active { + font-weight: bold; + } + } +} diff --git a/Resources/Private/Scss/frontend/frontend.scss b/Resources/Private/Scss/frontend/frontend.scss index 7545fd0b..4abac228 100644 --- a/Resources/Private/Scss/frontend/frontend.scss +++ b/Resources/Private/Scss/frontend/frontend.scss @@ -6,6 +6,7 @@ @import "components/blog/avatar"; @import "components/blog/archive"; @import "components/blog/badge"; +@import "components/blog/filter"; @import "components/blog/icons"; @import "components/blog/image"; @import "components/blog/linklist"; diff --git a/Resources/Private/Templates/Post/ListPostsByFilter.html b/Resources/Private/Templates/Post/ListPostsByFilter.html new file mode 100644 index 00000000..67db052c --- /dev/null +++ b/Resources/Private/Templates/Post/ListPostsByFilter.html @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/Private/Templates/Post/ListPostsByFilter.rss b/Resources/Private/Templates/Post/ListPostsByFilter.rss new file mode 100644 index 00000000..f32d842e --- /dev/null +++ b/Resources/Private/Templates/Post/ListPostsByFilter.rss @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/Public/Css/frontend.min.css b/Resources/Public/Css/frontend.min.css index f7e2fe95..4db1317f 100644 --- a/Resources/Public/Css/frontend.min.css +++ b/Resources/Public/Css/frontend.min.css @@ -1 +1 @@ -.blogcontainer{display:grid;grid-column-gap:40px}@media(min-width: 992px){.blogcontainer{grid-template-columns:1fr 300px}}.bloglist__item{margin-top:1.5rem}.bloglist__item:first-of-type{margin-top:0}.bloglist__image{margin-bottom:1rem}.bloglist__imageavatar{margin-left:auto;margin-right:auto}.bloglist__description{margin-top:.5rem;margin-bottom:.5rem}.blogavatar{display:block;border-radius:50%;overflow:hidden}.blogarchiveheader{margin-bottom:2.5rem}.blogarchiveheader__title{margin-bottom:.5rem}.blogarchiveheader__titletext{margin-right:.5rem}.blogarchivefooter{margin-top:2.5rem}.blogbadge{display:inline-block;padding:.5em;border-radius:2px;border:1px solid;line-height:1em}.blogbadge:hover{text-decoration:none}.blogicon{top:.125em;position:relative;display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-ms-flex-item-align:center;align-self:center}.blogicon svg{height:1em;width:1em}.blogimage img{max-width:100%;height:auto}.bloglinklist{padding:0;margin:0;list-style:none}.bloglinklist__itemcount{margin-left:.25rem}.bloglinklist__itemcount:before{content:"("}.bloglinklist__itemcount:after{content:")"}.blogpagination__list{margin-top:1.5rem;margin-bottom:0;display:-webkit-box;display:-ms-flexbox;display:flex;padding-left:0;list-style:none}.blogpagination__item:first-child .blogpagination__link{margin-left:0}.blogpagination__item--active{font-weight:bold}.blogpagination__link{display:block;padding:.5rem .75rem;margin-left:-1px;line-height:1;border:1px solid}.blogpagination__item--disabled .blogpagination__link{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;cursor:auto}.blogtaglist{padding:0;margin:-0.25rem !important;list-style:none}.blogtaglist__item{display:inline-block;vertical-align:middle;margin:.25rem}.blogwidget{margin-bottom:1.5rem}.blogwidget:last-child{margin-bottom:0}.blogwidget__content{overflow:hidden}.blogwidget__content>*:last-child{margin-bottom:0}.blogwidgetlist{padding:0;margin:0;list-style:none}.blogwidgetlist .blogwidgetlist{padding-left:1rem}.blogwidgetlist__itemcount{margin-left:.25rem}.blogwidgetlist__itemcount:before{content:"("}.blogwidgetlist__itemcount:after{content:")"}.blogwidgetlist__itemauthor{font-weight:bold}.blogwidgetlist--tags{margin:-0.25rem !important}.blogwidgetlist--tags .blogwidgetlist__item{display:inline-block;vertical-align:middle;margin:.25rem}.blogwidgetlist--recentcomments .blogwidgetlist__item+.blogwidgetlist__item{margin-top:1rem}.blogwidgetlist--recentcomments .blogwidgetlist__itemtext{margin-bottom:.25rem}.blogwidgetlist--recentcomments .blogwidgetlist__itemauthoron{margin-left:.25rem;margin-right:.25rem}.postauthor{display:-webkit-box;display:-ms-flexbox;display:flex}.postauthor+.postauthor{border-top:1px solid rgba(0,0,0,.15);padding-top:1.5rem;margin-top:1.5rem}.postauthor__avatar{margin-right:1rem}.postauthor__body{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}.postauthor__body>*:last-child{margin-bottom:0}.postauthor__intro{opacity:.75}.postauthor__name{font-weight:bold;font-size:1.25rem;line-height:1.5rem}.postauthor__sublinedivider{margin-right:.25rem}.postauthor__social{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-top:.25rem}.postauthor__social+.postauthor__actions{margin-top:.5rem}.postauthor__sociallink{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;margin-right:.5rem}.postauthor__sociallinklabel{position:absolute !important;clip:rect(1px, 1px, 1px, 1px) !important;padding:0 !important;border:0 !important;height:1px !important;width:1px !important;overflow:hidden !important}.postauthor__bio{margin-top:1rem;margin-bottom:1rem}.postauthor__bio p{margin-bottom:.5rem}.postauthor__bio>*:last-child{margin-bottom:0}.postauthor__actions{margin-bottom:1rem}.postcomment{display:-webkit-box;display:-ms-flexbox;display:flex}.postcomment+.postcomment{border-top:1px solid rgba(0,0,0,.15);padding-top:1.5rem;margin-top:1.5rem}.postcomment__avatar{margin-right:1rem}.postcomment__body{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}.postcomment__body>*:last-child{margin-bottom:0}.postcomment__author{font-weight:bold;font-size:1.25rem;line-height:1.5rem;margin-top:.5rem}.postcomment__comment{margin-top:1rem}.postcomment__comment p{margin-bottom:.5rem}.postcomment__comment>*:last-child{margin-bottom:0}.postlist__post{margin-top:1.5rem}.postlist__post:first-of-type{margin-top:0}.postlist__postdescription{margin-top:.5rem;margin-bottom:.5rem}.postteaser{display:grid;gap:1.5rem}.postteaser__posttitle{font-size:1.25rem}.postmetagroup{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;margin:-0.135rem -0.5rem;-ms-flex-wrap:wrap;flex-wrap:wrap}.postmetagroup__item{padding:.135rem .5rem;white-space:nowrap;width:100%}@media(min-width: 576px){.postmetagroup__item{width:auto}}.postmetagroup__icon,.postmetagroup__prefix{opacity:.75}.postmetagroup__item{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex}.postmetagroup__body{margin-left:.25rem}.postmetagroup__content{white-space:initial}.postmetagroup__list{padding:0;margin:0;list-style:none}.postmetagroup__list li{display:inline}.postmetagroup__list li:not(:last-child):after{display:inline;content:", ";margin-right:.25rem}.postmetagroup__listitem{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.postmetagroup__listprefix{margin-right:.25rem;line-height:1}.postmetagroup--simple .postmetagroup__prefix{position:absolute !important;clip:rect(1px, 1px, 1px, 1px) !important;padding:0 !important;border:0 !important;height:1px !important;width:1px !important;overflow:hidden !important}.postmetagroup--condensed .postmetagroup__body{display:-webkit-box;display:-ms-flexbox;display:flex}.postmetagroup--condensed .postmetagroup__prefix:after{display:inline;content:":"}.postmetagroup--condensed .postmetagroup__content{margin-left:.25rem}.alert__title{font-weight:bold}.alert__list{margin:0;padding:0;list-style:none} +.blogcontainer{display:grid;grid-column-gap:40px}@media(min-width: 992px){.blogcontainer{grid-template-columns:1fr 300px}}.bloglist__item{margin-top:1.5rem}.bloglist__item:first-of-type{margin-top:0}.bloglist__image{margin-bottom:1rem}.bloglist__imageavatar{margin-left:auto;margin-right:auto}.bloglist__description{margin-top:.5rem;margin-bottom:.5rem}.blogavatar{display:block;border-radius:50%;overflow:hidden}.blogarchiveheader{margin-bottom:2.5rem}.blogarchiveheader__title{margin-bottom:.5rem}.blogarchiveheader__titletext{margin-right:.5rem}.blogarchivefooter{margin-top:2.5rem}.blogbadge{display:inline-block;padding:.5em;border-radius:2px;border:1px solid;line-height:1em}.blogbadge:hover{text-decoration:none}.blogfilter__categories .blogfilter__category{display:inline-block}.blogfilter__categories .blogfilter__category.one{margin:1rem 0 1rem 1rem}.blogfilter__categories .blogfilter__category.active{font-weight:bold}.blogfilter__tags .blogfilter__tag{display:inline-block}.blogfilter__tags .blogfilter__tag.one{margin:1rem 0 1rem 1rem}.blogfilter__tags .blogfilter__tag.active{font-weight:bold}.blogicon{top:.125em;position:relative;display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-ms-flex-item-align:center;align-self:center}.blogicon svg{height:1em;width:1em}.blogimage img{max-width:100%;height:auto}.bloglinklist{padding:0;margin:0;list-style:none}.bloglinklist__itemcount{margin-left:.25rem}.bloglinklist__itemcount:before{content:"("}.bloglinklist__itemcount:after{content:")"}.blogpagination__list{margin-top:1.5rem;margin-bottom:0;display:-webkit-box;display:-ms-flexbox;display:flex;padding-left:0;list-style:none}.blogpagination__item:first-child .blogpagination__link{margin-left:0}.blogpagination__item--active{font-weight:bold}.blogpagination__link{display:block;padding:.5rem .75rem;margin-left:-1px;line-height:1;border:1px solid}.blogpagination__item--disabled .blogpagination__link{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;cursor:auto}.blogtaglist{padding:0;margin:-0.25rem !important;list-style:none}.blogtaglist__item{display:inline-block;vertical-align:middle;margin:.25rem}.blogwidget{margin-bottom:1.5rem}.blogwidget:last-child{margin-bottom:0}.blogwidget__content{overflow:hidden}.blogwidget__content>*:last-child{margin-bottom:0}.blogwidgetlist{padding:0;margin:0;list-style:none}.blogwidgetlist .blogwidgetlist{padding-left:1rem}.blogwidgetlist__itemcount{margin-left:.25rem}.blogwidgetlist__itemcount:before{content:"("}.blogwidgetlist__itemcount:after{content:")"}.blogwidgetlist__itemauthor{font-weight:bold}.blogwidgetlist--tags{margin:-0.25rem !important}.blogwidgetlist--tags .blogwidgetlist__item{display:inline-block;vertical-align:middle;margin:.25rem}.blogwidgetlist--recentcomments .blogwidgetlist__item+.blogwidgetlist__item{margin-top:1rem}.blogwidgetlist--recentcomments .blogwidgetlist__itemtext{margin-bottom:.25rem}.blogwidgetlist--recentcomments .blogwidgetlist__itemauthoron{margin-left:.25rem;margin-right:.25rem}.postauthor{display:-webkit-box;display:-ms-flexbox;display:flex}.postauthor+.postauthor{border-top:1px solid rgba(0,0,0,.15);padding-top:1.5rem;margin-top:1.5rem}.postauthor__avatar{margin-right:1rem}.postauthor__body{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}.postauthor__body>*:last-child{margin-bottom:0}.postauthor__intro{opacity:.75}.postauthor__name{font-weight:bold;font-size:1.25rem;line-height:1.5rem}.postauthor__sublinedivider{margin-right:.25rem}.postauthor__social{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-top:.25rem}.postauthor__social+.postauthor__actions{margin-top:.5rem}.postauthor__sociallink{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;margin-right:.5rem}.postauthor__sociallinklabel{position:absolute !important;clip:rect(1px, 1px, 1px, 1px) !important;padding:0 !important;border:0 !important;height:1px !important;width:1px !important;overflow:hidden !important}.postauthor__bio{margin-top:1rem;margin-bottom:1rem}.postauthor__bio p{margin-bottom:.5rem}.postauthor__bio>*:last-child{margin-bottom:0}.postauthor__actions{margin-bottom:1rem}.postcomment{display:-webkit-box;display:-ms-flexbox;display:flex}.postcomment+.postcomment{border-top:1px solid rgba(0,0,0,.15);padding-top:1.5rem;margin-top:1.5rem}.postcomment__avatar{margin-right:1rem}.postcomment__body{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}.postcomment__body>*:last-child{margin-bottom:0}.postcomment__author{font-weight:bold;font-size:1.25rem;line-height:1.5rem;margin-top:.5rem}.postcomment__comment{margin-top:1rem}.postcomment__comment p{margin-bottom:.5rem}.postcomment__comment>*:last-child{margin-bottom:0}.postlist__post{margin-top:1.5rem}.postlist__post:first-of-type{margin-top:0}.postlist__postdescription{margin-top:.5rem;margin-bottom:.5rem}.postteaser{display:grid;gap:1.5rem}.postteaser__posttitle{font-size:1.25rem}.postmetagroup{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;margin:-0.135rem -0.5rem;-ms-flex-wrap:wrap;flex-wrap:wrap}.postmetagroup__item{padding:.135rem .5rem;white-space:nowrap;width:100%}@media(min-width: 576px){.postmetagroup__item{width:auto}}.postmetagroup__icon,.postmetagroup__prefix{opacity:.75}.postmetagroup__item{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex}.postmetagroup__body{margin-left:.25rem}.postmetagroup__content{white-space:initial}.postmetagroup__list{padding:0;margin:0;list-style:none}.postmetagroup__list li{display:inline}.postmetagroup__list li:not(:last-child):after{display:inline;content:", ";margin-right:.25rem}.postmetagroup__listitem{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.postmetagroup__listprefix{margin-right:.25rem;line-height:1}.postmetagroup--simple .postmetagroup__prefix{position:absolute !important;clip:rect(1px, 1px, 1px, 1px) !important;padding:0 !important;border:0 !important;height:1px !important;width:1px !important;overflow:hidden !important}.postmetagroup--condensed .postmetagroup__body{display:-webkit-box;display:-ms-flexbox;display:flex}.postmetagroup--condensed .postmetagroup__prefix:after{display:inline;content:":"}.postmetagroup--condensed .postmetagroup__content{margin-left:.25rem}.alert__title{font-weight:bold}.alert__list{margin:0;padding:0;list-style:none} diff --git a/Resources/Public/Icons/plugin-blog-filter.svg b/Resources/Public/Icons/plugin-blog-filter.svg new file mode 100644 index 00000000..6f2250eb --- /dev/null +++ b/Resources/Public/Icons/plugin-blog-filter.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/Tests/AssertionsTrait.php b/Tests/AssertionsTrait.php new file mode 100644 index 00000000..0d882cfd --- /dev/null +++ b/Tests/AssertionsTrait.php @@ -0,0 +1,67 @@ + + */ + protected const CATEGORIES = [1 => 'category1', 2 => 'category2']; + + /** + * @type array + */ + protected const TAGS = [1 => 'tag1', 2 => 'tag2']; + + /** + * @var array + */ + protected static array $categories = []; + + /** + * @var array + */ + protected static array $tags = []; + + protected static CategoryRepository $categoryRepository; + + protected static TagRepository $tagRepository; + + public static function setUpBeforeClass(): void + { + foreach (self::CATEGORIES as $uid => $title) { + $category = new Category(); + $category->setTitle($title); + self::$categories[$uid] = $category; + } + $categoryRepository = self::createStub(CategoryRepository::class); + $categoryRepository->method('findByUid')->willReturnCallback(fn(int $uid) => self::$categories[$uid]); + self::$categoryRepository = $categoryRepository; + + foreach (self::TAGS as $uid => $title) { + $tag = new Tag(); + $tag->setTitle($title); + self::$tags[$uid] = $tag; + } + $tagRepository = self::createStub(TagRepository::class); + $tagRepository->method('findByUid')->willReturnCallback(fn(int $uid) => self::$tags[$uid]); + self::$tagRepository = $tagRepository; + } + + /** + * @test + */ + public function constructor(): PostFilterFactory + { + return new PostFilterFactory(self::$categoryRepository, self::$tagRepository); + } + + /** + * @test + * @depends constructor + */ + public function getFilterFromRequestForEmptyRequest(PostFilterFactory $factory): void + { + /** @var CategoryTag $filter */ + $filter = $factory->getFilterFromRequest($this->createRequest()); + self::assertInstanceOf(CategoryTag::class, $filter); + self::assertNull($filter->getCategory()); + self::assertNull($filter->getTag()); + } + + /** + * @test + * @depends constructor + */ + public function getFilterFromRequestForSelectedCategory(PostFilterFactory $factory): void + { + /** @var CategoryTag $filter */ + $filter = $factory->getFilterFromRequest( + $this->createRequest([ + self::REQUEST_CATEGORY => 1 + ]) + ); + self::assertInstanceOf(CategoryTag::class, $filter); + self::assertSame(self::$categories[1], $filter->getCategory()); + self::assertNull($filter->getTag()); + } + + /** + * @test + * @depends constructor + */ + public function getFilterFromRequestForSelectedTag(PostFilterFactory $factory): void + { + /** @var CategoryTag $filter */ + $filter = $factory->getFilterFromRequest( + $this->createRequest([ + self::REQUEST_TAG => 2 + ]) + ); + self::assertInstanceOf(CategoryTag::class, $filter); + self::assertNull($filter->getCategory()); + self::assertSame(self::$tags[2], $filter->getTag()); + } + + /** + * @test + * @depends constructor + */ + public function getFilterFromRequestForSelectedCategoryAndTag(PostFilterFactory $factory): void + { + /** @var CategoryTag $filter */ + $filter = $factory->getFilterFromRequest( + $this->createRequest([ + self::REQUEST_CATEGORY => 2, + self::REQUEST_TAG => 1 + ]) + ); + self::assertInstanceOf(CategoryTag::class, $filter); + self::assertSame(self::$categories[2], $filter->getCategory()); + self::assertSame(self::$tags[1], $filter->getTag()); + } + + protected function createRequest(array $arguments = []): RequestInterface + { + /** @var RequestInterface $request */ + $request = self::createMock(RequestInterface::class); + $request + ->expects($this->exactly(2)) + ->method('hasArgument') + ->willReturnCallback(fn(string $argumentName) => array_key_exists($argumentName, $arguments)); + $request + ->expects($this->exactly(count($arguments))) + ->method('getArgument') + ->willReturnCallback(fn(string $argumentName) => $arguments[$argumentName]); + return $request; + } +} diff --git a/Tests/Unit/Domain/Filter/Post/CategoryTagTest.php b/Tests/Unit/Domain/Filter/Post/CategoryTagTest.php new file mode 100644 index 00000000..e38bf591 --- /dev/null +++ b/Tests/Unit/Domain/Filter/Post/CategoryTagTest.php @@ -0,0 +1,252 @@ +getTitle()); + return $filter; + } + + /** + * @test + * @depends constructor + */ + public function getDescriptionForEmptyFilter(CategoryTag $filter): CategoryTag + { + self::assertEmpty($filter->getDescription()); + return $filter; + } + + /** + * @test + * @depends constructor + */ + public function getConstraintsForEmptyFilter(CategoryTag $filter): void + { + self::assertEmpty($filter->getConstraints(self::createStub(QueryInterface::class))); + } + + /** + * @test + * @depends constructor + */ + public function getNameReturnsClassNameWithoutNamespace(CategoryTag $filter): void + { + self::assertSame('CategoryTag', $filter->getName()); + } + + /** + * @test + * @depends constructor + */ + public function getCategoryReturnsNullInitially(CategoryTag $filter): CategoryTag + { + self::assertNull($filter->getCategory()); + return $filter; + } + + /** + * @test + * @depends constructor + */ + public function getTagReturnsNullInitially(CategoryTag $filter): CategoryTag + { + self::assertNull($filter->getTag()); + return $filter; + } + + /** + * @test + */ + public function getCategoryAfterSetCategory(): CategoryTag + { + $filter = new CategoryTag(); + $category = $this->createCategory(); + $filter->setCategory($category); + self::assertSame($category, $filter->getCategory()); + return $filter; + } + + /** + * @test + */ + public function getTagAfterSetTag(): CategoryTag + { + $filter = new CategoryTag(); + $tag = $this->createTag(); + $filter->setTag($tag); + self::assertSame($tag, $filter->getTag()); + return $filter; + } + + /** + * @test + * @depends getCategoryAfterSetCategory + */ + public function getTitleForCategoryFilter(CategoryTag $filter): void + { + self::assertSame(self::CATEGORY_TITLE, $filter->getTitle()); + } + + /** + * @test + * @depends getCategoryAfterSetCategory + */ + public function getDescriptionForCategoryFilter(CategoryTag $filter): void + { + self::assertSame(self::CATEGORY_DESCRIPTION, $filter->getDescription()); + } + + /** + * @test + * @depends getCategoryAfterSetCategory + */ + public function getConstraintsForCategoryFilter(CategoryTag $filter): void + { + $query = self::createMock(QueryInterface::class); + $query + ->expects($this->once()) + ->method('contains')->with(self::QUERY_CATEGORIES, $this->createCategory()) + ->willReturnCallback(fn(string $name, Category $category) => [$name => $category->getTitle()]); + $constraints = $filter->getConstraints($query); + self::assertArrayOfArrays(1, $constraints, 0); + self::assertArrayKeyValue(self::QUERY_CATEGORIES, self::CATEGORY_TITLE, $constraints[0]); + } + + /** + * @test + * @depends getTagAfterSetTag + */ + public function getTitleForTagFilter(CategoryTag $filter): void + { + self::assertSame(self::TAG_TITLE, $filter->getTitle()); + } + + /** + * @test + * @depends getTagAfterSetTag + */ + public function getDescriptionForTagFilter(CategoryTag $filter): void + { + self::assertSame(self::TAG_DESCRIPTION, $filter->getDescription()); + } + + /** + * @test + * @depends getTagAfterSetTag + */ + public function getConstraintsForTagFilter(CategoryTag $filter): void + { + $query = self::createMock(QueryInterface::class); + $query + ->expects($this->once()) + ->method('contains')->with(self::QUERY_TAGS, $this->createTag()) + ->willReturnCallback(fn(string $name, Tag $tag) => [$name => $tag->getTitle()]); + $constraints = $filter->getConstraints($query); + self::assertArrayOfArrays(1, $constraints, 0); + self::assertArrayKeyValue(self::QUERY_TAGS, self::TAG_TITLE, $constraints[0]); + } + + /** + * @test + * @depends getCategoryAfterSetCategory + */ + public function getTitleForCategoryTagFilter(CategoryTag $filter): CategoryTag + { + $filter->setTag($this->createTag()); + self::assertSame(self::CATEGORY_TITLE . ' ' . self::TAG_TITLE, $filter->getTitle()); + return $filter; + } + + /** + * @test + * @depends getTitleForCategoryTagFilter + */ + public function getDescriptionForCategoryTagFilter(CategoryTag $filter): void + { + self::assertSame(self::CATEGORY_DESCRIPTION . ' ' . self::TAG_DESCRIPTION, $filter->getDescription()); + } + + /** + * @test + * @depends getTitleForCategoryTagFilter + */ + public function getConstraintsForCategoryTagFilter(CategoryTag $filter): void + { + $query = self::createMock(QueryInterface::class); + $query + ->expects($this->exactly(2)) + ->method('contains') + ->willReturnCallback(fn(string $name, $categoryOrTag) => [$name => $categoryOrTag->getTitle()]); + $constraints = $filter->getConstraints($query); + self::assertArrayOfArrays(2, $constraints, [0, 1]); + self::assertArrayKeyValue(self::QUERY_CATEGORIES, self::CATEGORY_TITLE, $constraints[0]); + self::assertArrayKeyValue(self::QUERY_TAGS, self::TAG_TITLE, $constraints[1]); + } + + protected function createCategory(): Category + { + $category = new Category(); + $category->setTitle(self::CATEGORY_TITLE); + $category->setDescription(self::CATEGORY_DESCRIPTION); + return $category; + } + + protected function createTag(): Tag + { + $tag = new Tag(); + $tag->setTitle(self::TAG_TITLE); + $tag->setDescription(self::TAG_DESCRIPTION); + return $tag; + } +} diff --git a/ext_emconf.php b/ext_emconf.php index 5317ec76..71d0f723 100644 --- a/ext_emconf.php +++ b/ext_emconf.php @@ -14,7 +14,7 @@ 'state' => 'stable', 'author' => 'TYPO3 GmbH', 'author_email' => 'info@typo3.com', - 'version' => '12.0.2', + 'version' => '12.1.0', 'constraints' => [ 'depends' => [ 'typo3' => '11.5.0-12.4.99', diff --git a/ext_localconf.php b/ext_localconf.php index 19f38d17..7ac725fc 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -117,6 +117,14 @@ ] ); +ExtensionUtility::configurePlugin( + 'Blog', + 'Filter', + [ + PostController::class => 'listPostsByFilter', + ] +); + ExtensionUtility::configurePlugin( 'Blog', 'Sidebar', diff --git a/ext_tables.php b/ext_tables.php index 2fdc61d8..9b38127f 100644 --- a/ext_tables.php +++ b/ext_tables.php @@ -51,6 +51,7 @@ 'plugin-blog-relatedposts' => 'EXT:blog/Resources/Public/Icons/plugin-blog-relatedposts.svg', 'plugin-blog-sidebar' => 'EXT:blog/Resources/Public/Icons/plugin-blog-sidebar.svg', 'plugin-blog-tag' => 'EXT:blog/Resources/Public/Icons/plugin-blog-tag.svg', + 'plugin-blog-filter' => 'EXT:blog/Resources/Public/Icons/plugin-blog-filter.svg', 'record-blog-author' => 'EXT:blog/Resources/Public/Icons/record-blog-author.svg', 'record-blog-comment' => 'EXT:blog/Resources/Public/Icons/record-blog-comment.svg', 'record-blog-page' => 'EXT:blog/Resources/Public/Icons/record-blog-page.svg',