├── Resources └── Private │ ├── Fusion │ ├── Root.fusion │ ├── Utility.fusion │ ├── Collection.fusion │ ├── List.fusion │ ├── PaginatedCollection.fusion │ └── Pagination.fusion │ └── Translations │ ├── en │ └── Main.xlf │ └── de │ └── Main.xlf ├── Configuration ├── Settings.yaml └── Routes.yaml ├── LICENSE.md ├── Classes └── Fusion │ ├── PaginationArrayImplementation.php │ └── Eel │ └── FlowQueryOperations │ ├── FilterByDateOperation.php │ └── SortRecursiveByIndexOperation.php ├── composer.json └── README.md /Resources/Private/Fusion/Root.fusion: -------------------------------------------------------------------------------- 1 | include: Utility.fusion 2 | include: Pagination.fusion 3 | include: Collection.fusion 4 | include: PaginatedCollection.fusion 5 | include: List.fusion 6 | -------------------------------------------------------------------------------- /Configuration/Settings.yaml: -------------------------------------------------------------------------------- 1 | Neos: 2 | Flow: 3 | mvc: 4 | routes: 5 | 'Flowpack.Listable': 6 | position: 'before Neos.Neos' 7 | variables: 8 | pageSeparator: '~p' 9 | defaultUriSuffix: '.html' 10 | Neos: 11 | fusion: 12 | autoInclude: 13 | Flowpack.Listable: true 14 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Utility.fusion: -------------------------------------------------------------------------------- 1 | # This object is used to render items in a list automatically with object of type ObjectType + Short 2 | prototype(Flowpack.Listable:ContentCaseShort) < prototype(Neos.Neos:ContentCase) { 3 | default { 4 | @position = 'end' 5 | condition = true 6 | type = ${node.nodeTypeName + '.Short'} 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Configuration/Routes.yaml: -------------------------------------------------------------------------------- 1 | - 2 | name: 'Paginate for Flowpack.Listable' 3 | uriPattern: '{node}{currentPage}' 4 | defaults: 5 | '@package': 'Neos.Neos' 6 | '@controller': 'Frontend\Node' 7 | '@format': 'html' 8 | '@action': 'show' 9 | routeParts: 10 | node: 11 | handler: Neos\Neos\FrontendRouting\FrontendNodeRoutePartHandlerInterface 12 | appendExceedingArguments: TRUE 13 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Collection.fusion: -------------------------------------------------------------------------------- 1 | prototype(Flowpack.Listable:Collection) < prototype(Neos.Fusion:Loop) { 2 | listClass = '' 3 | itemClass = '' 4 | @context.itemClass = ${this.itemClass} 5 | @process.tmpl = ${''} 6 | 7 | items = 'must-be-set' 8 | itemName = 'node' 9 | iterationName = 'iteration' 10 | itemRenderer = Flowpack.Listable:ContentCaseShort 11 | itemRenderer.@process.tmpl = ${'
  • ' + value + '
  • '} 12 | } 13 | -------------------------------------------------------------------------------- /Resources/Private/Translations/en/Main.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | next 7 | 8 | 9 | previous 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Resources/Private/Translations/de/Main.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | next 7 | Nächste 8 | 9 | 10 | previous 11 | Vorherige 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 St Philaret`s Christian Orthodox Institute 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/List.fusion: -------------------------------------------------------------------------------- 1 | # This convienince object wraps your list with a title and an archive link 2 | prototype(Flowpack.Listable:List) < prototype(Neos.Fusion:Component) { 3 | list = ${value} 4 | # These settings are public API: 5 | wrapClass = '' 6 | listTitle = '' 7 | listTitleClass = '' 8 | archiveLink = '' 9 | archiveLinkTitle = '' 10 | archiveLinkClass = '' 11 | archiveLinkAdditionalParams = ${{}} 12 | 13 | renderer = Neos.Fusion:Tag { 14 | # Don't render the List object if the list is empty 15 | @if.listNotEmpty = ${props.list != null} 16 | 17 | attributes.class = ${props.wrapClass} 18 | content = Neos.Fusion:Join { 19 | listTitleTag = Neos.Fusion:Tag { 20 | tagName = 'h2' 21 | attributes.class = ${props.listTitleClass} 22 | attributes.class.@if.isSet = ${props.listTitleClass ? true : false} 23 | content = ${props.listTitle} 24 | @if.isSet = ${!String.isBlank(props.listTitle) && !String.isBlank(props.list)} 25 | } 26 | list = ${props.list} 27 | archiveLink = Neos.Fusion:Tag { 28 | tagName = 'a' 29 | attributes.class = ${props.archiveLinkClass} 30 | attributes.href = Neos.Neos:NodeUri { 31 | node = ${props.archiveLink} 32 | additionalParams = ${props.archiveLinkAdditionalParams} 33 | } 34 | content = ${props.archiveLinkTitle} 35 | @if.isSet = ${props.archiveLink != ''} 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Classes/Fusion/PaginationArrayImplementation.php: -------------------------------------------------------------------------------- 1 | fusionValue('showPreviousNextLinks'); 15 | $maximumNumberOfLinks = $this->fusionValue('maximumNumberOfLinks') - 2; 16 | $itemsPerPage = $this->fusionValue('itemsPerPage'); 17 | $totalCount = $this->fusionValue('totalCount'); 18 | $currentPage = $this->fusionValue('currentPage'); 19 | if ($totalCount > 0 !== true) { 20 | return []; 21 | } 22 | $numberOfPages = ceil($totalCount / $itemsPerPage); 23 | if ($maximumNumberOfLinks > $numberOfPages) { 24 | $maximumNumberOfLinks = $numberOfPages; 25 | } 26 | $delta = floor($maximumNumberOfLinks / 2); 27 | $displayRangeStart = $currentPage - $delta; 28 | $displayRangeEnd = $currentPage + $delta + ($maximumNumberOfLinks % 2 === 0 ? 1 : 0); 29 | if ($displayRangeStart < 1) { 30 | $displayRangeEnd -= $displayRangeStart - 1; 31 | } 32 | if ($displayRangeEnd > $numberOfPages) { 33 | $displayRangeStart -= ($displayRangeEnd - $numberOfPages); 34 | } 35 | $displayRangeStart = (integer)max($displayRangeStart, 1); 36 | $displayRangeEnd = (integer)min($displayRangeEnd, $numberOfPages); 37 | $links = \range($displayRangeStart, $displayRangeEnd); 38 | if ($displayRangeStart > 2) { 39 | array_unshift($links, '...'); 40 | array_unshift($links, 1); 41 | } 42 | if ($displayRangeEnd + 1 < $numberOfPages) { 43 | $links[] = '...'; 44 | $links[] = $numberOfPages; 45 | } 46 | 47 | if ($showPreviousNextLinks) { 48 | if ($currentPage > 1) { 49 | array_unshift($links, 'previous'); 50 | } 51 | if ($currentPage < $numberOfPages) { 52 | $links[] = 'next'; 53 | } 54 | } 55 | return $links; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/PaginatedCollection.fusion: -------------------------------------------------------------------------------- 1 | prototype(Flowpack.Listable:PaginatedCollection) < prototype(Neos.Fusion:Component) { 2 | currentPage = ${String.toInteger(request.arguments.currentPage) || 1} 3 | ################################## 4 | # These settings are public API: # 5 | ################################## 6 | collection = 'must-be-set' 7 | itemsPerPage = 24 8 | maximumNumberOfLinks = 15 9 | showPreviousNextLinks = false 10 | listRenderer = 'Flowpack.Listable:Collection' 11 | 12 | renderer = Neos.Fusion:Join { 13 | @context.data = Neos.Fusion:DataStructure { 14 | collection = Neos.Fusion:Case { 15 | @context.limit = ${props.currentPage * props.itemsPerPage} 16 | @context.offset = ${(props.currentPage - 1) * props.itemsPerPage} 17 | elasticSearch { 18 | condition = ${Type.instance(props.collection, 'Flowpack\ElasticSearch\ContentRepositoryAdaptor\Eel\ElasticSearchQueryBuilder')} 19 | renderer = ${props.collection} 20 | renderer.@process.limit = ${value.limit(props.itemsPerPage)} 21 | renderer.@process.offset = ${value.from(offset)} 22 | renderer.@process.execute = ${value.execute()} 23 | } 24 | default { 25 | condition = ${true} 26 | renderer = ${Type.instance(props.collection, 'Neos\Eel\FlowQuery\FlowQuery') ? props.collection : q(props.collection)} 27 | renderer.@process.slice = ${value.slice(offset, limit)} 28 | renderer.@process.execute = ${value.get()} 29 | } 30 | } 31 | totalCount = ${Type.getType(props.collection) == 'array' ? q(props.collection).count() : props.collection.count()} 32 | } 33 | 34 | list = Neos.Fusion:Renderer { 35 | type = ${props.listRenderer} 36 | element.items = ${data.collection} 37 | } 38 | pagination = Flowpack.Listable:Pagination { 39 | currentPage = ${props.currentPage} 40 | totalCount = ${data.totalCount} 41 | maximumNumberOfLinks = ${props.maximumNumberOfLinks} 42 | itemsPerPage = ${props.itemsPerPage} 43 | showPreviousNextLinks = ${props.showPreviousNextLinks} 44 | } 45 | } 46 | 47 | @cache { 48 | mode = 'dynamic' 49 | entryIdentifier { 50 | node = ${Neos.Caching.entryIdentifierForNode(node)} 51 | } 52 | entryDiscriminator = ${request.arguments.currentPage} 53 | context { 54 | 1 = 'node' 55 | 2 = 'documentNode' 56 | 3 = 'site' 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Classes/Fusion/Eel/FlowQueryOperations/FilterByDateOperation.php: -------------------------------------------------------------------------------- 1 | ', '>' by default 47 | * 48 | * @return void 49 | * @throws FlowQueryException 50 | */ 51 | public function evaluate(FlowQuery $flowQuery, array $arguments) 52 | { 53 | if (empty($arguments[0])) { 54 | throw new FlowQueryException('filterByDate() needs property name by which nodes should be filtered', 1332492263); 55 | } 56 | if (empty($arguments[1])) { 57 | throw new FlowQueryException('filterByDate() needs date value by which nodes should be filtered', 1332493263); 58 | } 59 | 60 | /** @var \DateTime $date */ 61 | list($filterByPropertyPath, $date) = $arguments; 62 | $compareOperator = '>'; 63 | if (!empty($arguments[2]) && in_array($arguments[2], ['<', '>'], true)) { 64 | $compareOperator = $arguments[2]; 65 | } 66 | 67 | $filteredNodes = []; 68 | foreach ($flowQuery->getContext() as $node) { 69 | /** @var Node $node */ 70 | $propertyValue = $node->getProperty($filterByPropertyPath); 71 | if (($compareOperator === '>' && $propertyValue > $date) || ($compareOperator === '<' && $propertyValue < $date)) { 72 | $filteredNodes[] = $node; 73 | } 74 | } 75 | 76 | $flowQuery->setContext($filteredNodes); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flowpack/listable", 3 | "replace": { 4 | "sfi/listable": "v0.1" 5 | }, 6 | "type": "neos-package", 7 | "description": "Tiny extension for listing things", 8 | "license": "MIT", 9 | "require": { 10 | "php": ">=8.2", 11 | "neos/neos": "^9.0" 12 | }, 13 | "autoload": { 14 | "psr-4": { 15 | "Flowpack\\Listable\\": "Classes" 16 | } 17 | }, 18 | "extra": { 19 | "applied-flow-migrations": [ 20 | "TYPO3.FLOW3-201201261636", 21 | "TYPO3.Fluid-201205031303", 22 | "TYPO3.FLOW3-201205292145", 23 | "TYPO3.FLOW3-201206271128", 24 | "TYPO3.FLOW3-201209201112", 25 | "TYPO3.Flow-201209251426", 26 | "TYPO3.Flow-201211151101", 27 | "TYPO3.Flow-201212051340", 28 | "TYPO3.TypoScript-130516234520", 29 | "TYPO3.TypoScript-130516235550", 30 | "TYPO3.TYPO3CR-130523180140", 31 | "TYPO3.Flow-201310031523", 32 | "TYPO3.Flow-201405111147", 33 | "TYPO3.Neos-201407061038", 34 | "TYPO3.Neos-201409071922", 35 | "TYPO3.TYPO3CR-140911160326", 36 | "TYPO3.Neos-201410010000", 37 | "TYPO3.TYPO3CR-141101082142", 38 | "TYPO3.Neos-20141113115300", 39 | "TYPO3.Fluid-20141113120800", 40 | "TYPO3.Flow-20141113121400", 41 | "TYPO3.Fluid-20141121091700", 42 | "TYPO3.Neos-20141218134700", 43 | "TYPO3.Fluid-20150214130800", 44 | "TYPO3.Neos-20150303231600", 45 | "TYPO3.TYPO3CR-20150510103823", 46 | "TYPO3.Flow-20151113161300", 47 | "TYPO3.Form-20160601101500", 48 | "TYPO3.Flow-20161115140400", 49 | "TYPO3.Flow-20161115140430", 50 | "Neos.Flow-20161124204700", 51 | "Neos.Flow-20161124204701", 52 | "Neos.Twitter.Bootstrap-20161124204912", 53 | "Neos.Form-20161124205254", 54 | "Neos.Flow-20161124224015", 55 | "Neos.Party-20161124225257", 56 | "Neos.Eel-20161124230101", 57 | "Neos.Kickstart-20161124230102", 58 | "Neos.Setup-20161124230842", 59 | "Neos.Imagine-20161124231742", 60 | "Neos.Media-20161124233100", 61 | "Neos.SiteKickstarter-20161125002311", 62 | "Neos.Neos-20161125002322", 63 | "Neos.ContentRepository-20161125012000", 64 | "Neos.Fusion-20161125013710", 65 | "Neos.Setup-20161125014759", 66 | "Neos.SiteKickstarter-20161125095901", 67 | "Neos.Fusion-20161125104701", 68 | "Neos.Neos-20161125104802", 69 | "Neos.Kickstarter-20161125110814", 70 | "Neos.Neos-20161125122412", 71 | "Neos.Flow-20161125124112", 72 | "TYPO3.FluidAdaptor-20161130112935", 73 | "Neos.Fusion-20161201202543", 74 | "Neos.Neos-20161201222211", 75 | "Neos.Fusion-20161202215034", 76 | "Neos.Fusion-20161219092345", 77 | "Neos.ContentRepository-20161219093512", 78 | "Neos.Media-20161219094126", 79 | "Neos.Neos-20161219094403", 80 | "Neos.Neos-20161219122512", 81 | "Neos.Fusion-20161219130100", 82 | "Neos.Neos-20161220163741", 83 | "TYPO3.Neos.NodeTypes-201309111655", 84 | "Neos.NodeTypes-20161125002300", 85 | "Neos.NodeTypes-20161125104800", 86 | "Neos.SwiftMailer-20161130105617", 87 | "Neos.ContentRepository.Search-20161210231100", 88 | "Neos.Neos-20170115114620", 89 | "Neos.Fusion-20170120013047", 90 | "Neos.Flow-20170125103800", 91 | "Neos.Seo-20170127154600", 92 | "Neos.Flow-20170127183102", 93 | "Neos.Fusion-20180211175500", 94 | "Neos.Fusion-20180211184832", 95 | "Neos.Flow-20180415105700" 96 | ] 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Pagination.fusion: -------------------------------------------------------------------------------- 1 | prototype(Flowpack.Listable:PaginationArray) { 2 | @class = 'Flowpack\\Listable\\Fusion\\PaginationArrayImplementation' 3 | currentPage = '' 4 | maximumNumberOfLinks = '' 5 | totalCount = '' 6 | itemsPerPage = '' 7 | showPreviousNextLinks = false 8 | } 9 | 10 | prototype(Flowpack.Listable:PaginationParameters) < prototype(Neos.Fusion:DataStructure) 11 | 12 | prototype(Flowpack.Listable:Pagination) < prototype(Neos.Fusion:Component) { 13 | totalCount = 'to-be-set' 14 | maximumNumberOfLinks = 15 15 | itemsPerPage = 24 16 | showPreviousNextLinks = false 17 | 18 | class = 'Pagination' 19 | itemClass = 'Pagination-item' 20 | currentItemClass = 'isCurrent' 21 | currentPage = ${request.arguments.currentPage || 1} 22 | 23 | renderer = Neos.Fusion:Loop { 24 | @if.paginationNeeded = ${(props.totalCount/props.itemsPerPage) > 1} 25 | @process.tmpl = ${''} 26 | items = Flowpack.Listable:PaginationArray { 27 | currentPage = ${props.currentPage} 28 | maximumNumberOfLinks = ${props.maximumNumberOfLinks} 29 | totalCount = ${props.totalCount} 30 | itemsPerPage = ${props.itemsPerPage} 31 | showPreviousNextLinks = ${props.showPreviousNextLinks} 32 | } 33 | itemName = 'i' 34 | itemRenderer = Neos.Fusion:Case { 35 | separator { 36 | condition = ${i == '...'} 37 | renderer = ${'
  • ' + i + '
  • '} 38 | } 39 | currentPage { 40 | condition = ${String.toInteger(i) == String.toInteger(props.currentPage)} 41 | renderer = ${'
  • ' + i + '
  • '} 42 | } 43 | previous { 44 | condition = ${i == 'previous' && (props.showPreviousNextLinks == true)} 45 | renderer = Neos.Fusion:Tag { 46 | @process.tmpl = ${''} 47 | tagName = 'a' 48 | attributes.rel = 'prev' 49 | attributes.href = Neos.Neos:NodeUri { 50 | node = ${documentNode} 51 | additionalParams = Flowpack.Listable:PaginationParameters { 52 | currentPage = ${String.toInteger(props.currentPage) - 1} 53 | } 54 | } 55 | content = ${Translation.translate(i, i, [], 'Main', 'Flowpack.Listable') } 56 | } 57 | } 58 | next { 59 | condition = ${i == 'next' && (props.showPreviousNextLinks == true)} 60 | renderer = Neos.Fusion:Tag { 61 | @process.tmpl = ${''} 62 | tagName = 'a' 63 | attributes.rel = 'next' 64 | attributes.href = Neos.Neos:NodeUri { 65 | node = ${documentNode} 66 | additionalParams = Flowpack.Listable:PaginationParameters { 67 | currentPage = ${String.toInteger(props.currentPage) + 1} 68 | } 69 | } 70 | content = ${Translation.translate(i, i, [], 'Main', 'Flowpack.Listable') } 71 | } 72 | } 73 | link { 74 | condition = ${(iterator.isFirst == false && iterator.isLast == false) || (props.showPreviousNextLinks == false)} 75 | renderer = Neos.Fusion:Tag { 76 | @process.tmpl = ${'
  • ' + value + '
  • '} 77 | tagName = 'a' 78 | attributes.href = Neos.Neos:NodeUri { 79 | node = ${documentNode} 80 | additionalParams = Flowpack.Listable:PaginationParameters { 81 | currentPage = ${i} 82 | } 83 | } 84 | content = ${i} 85 | } 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Classes/Fusion/Eel/FlowQueryOperations/SortRecursiveByIndexOperation.php: -------------------------------------------------------------------------------- 1 | getContext(); 65 | if (count($nodes) <= 1) { 66 | return; 67 | } 68 | 69 | $pathMap = []; 70 | $subgraph = $this->contentRepositoryRegistry->subgraphForNode($nodes[0]); 71 | 72 | // Collect all NodeAggregateId paths 73 | /** @var Node $node */ 74 | foreach ($nodes as $node) { 75 | $nodeIdentifier = $node->aggregateId->value; 76 | $ancestors = $subgraph->findAncestorNodes($node->aggregateId, FindAncestorNodesFilter::create()); 77 | $pathMap[$nodeIdentifier] = array_merge([$node->aggregateId], array_map(static fn($ancestor) => $ancestor->aggregateId, iterator_to_array($ancestors))); 78 | } 79 | 80 | $flip = $sortOrder === 'DESC' ? -1 : 1; 81 | 82 | usort($nodes, function (Node $a, Node $b) use ($subgraph, $pathMap, $flip) { 83 | // Both nodes are equal 84 | if ($a->equals($b)) { 85 | return 0; 86 | } 87 | 88 | $commonParentPathSegmentNodeAggregateId = null; 89 | $childNodesCache = []; 90 | 91 | // Compare path starting from the site root until a difference is found. 92 | $aPath = $pathMap[$a->aggregateId->value]; 93 | $bPath = $pathMap[$b->aggregateId->value]; 94 | while (count($aPath) > 0 && count($bPath) > 0) { 95 | 96 | /** @var NodeAggregateId $aPathSegmentNodeAggregateId */ 97 | $aPathSegmentNodeAggregateId = array_pop($aPath); 98 | /** @var NodeAggregateId $bPathSegmentNodeAggregateId */ 99 | $bPathSegmentNodeAggregateId = array_pop($bPath); 100 | 101 | $pathDiff = (!$aPathSegmentNodeAggregateId->equals($bPathSegmentNodeAggregateId)); 102 | 103 | if ($pathDiff === true) { 104 | // Path is different at this segment, so we need to figure out their position under the last common parent. 105 | if ($commonParentPathSegmentNodeAggregateId === null) { 106 | return 0; 107 | } 108 | 109 | if (!isset($childNodesCache[$commonParentPathSegmentNodeAggregateId->value])) { 110 | $childNodesCache[$commonParentPathSegmentNodeAggregateId->value] = $subgraph->findChildNodes($commonParentPathSegmentNodeAggregateId, FindChildNodesFilter::create()); 111 | } 112 | 113 | $positionDiff = $this->getIndexOfNodeAggregateIdInNodes($childNodesCache[$commonParentPathSegmentNodeAggregateId->value], $aPathSegmentNodeAggregateId) 114 | - $this->getIndexOfNodeAggregateIdInNodes($childNodesCache[$commonParentPathSegmentNodeAggregateId->value], $bPathSegmentNodeAggregateId); 115 | return $flip * $positionDiff < 0 ? -1 : 1; 116 | } 117 | // No diff in path, we need to go deeper, or they are eventually equal 118 | $commonParentPathSegmentNodeAggregateId = $aPathSegmentNodeAggregateId; 119 | } 120 | 121 | return 0; 122 | }); 123 | 124 | $flowQuery->setContext($nodes); 125 | } 126 | 127 | private function getIndexOfNodeAggregateIdInNodes(Nodes $childNodes, NodeAggregateId $nodeAggregateId): int 128 | { 129 | foreach ($childNodes as $key => $childNode) { 130 | if ($childNode->aggregateId->value === $nodeAggregateId->value) { 131 | return $key; 132 | } 133 | } 134 | 135 | throw new \Exception("Exception on sorting nodes by there position in content tree."); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Listable 2 | 3 | This Neos package solves one problem: help you list any nodes in Fusion. 4 | The idea is very simple: you often need to display list of things (e.g. news, articles etc), and the concern of listing items should better be separated from the concern of rendering items. This package provides a solid foundation for listing, while allowing you to take care of rendering stuff on your own. 5 | 6 | # TL;DR 7 | 8 | 1. Install the package with composer: `composer require flowpack/listable` [Here it is on packagist](https://packagist.org/packages/flowpack/listable). 9 | 2. If you want a paginated list, use `Flowpack.Listable:PaginatedCollection`. 10 | 3. If you just want a simple list, use `Flowpack.Listable:Collection` (or just `Neos.Fusion:Collection`!). 11 | 4. If you need a list with a header and an archive link, wrap you list into `Flowpack.Listable:List` 12 | 5. For each of your nodetypes create a new Fusion object of type NodeTypeName + '.Short', or manually define a rendering object. 13 | 6. Rely on public API keys when overriding settings. 14 | 15 | # Fusion objects 16 | 17 | Keys documented here are considered public API and would be treated with semantic versioning in mind. Extend all other properties at your own risk. 18 | 19 | ## Flowpack.Listable:Collection 20 | 21 | This object is just a simple convienince wrapper around `Neos.Fusion:Collection`, use it if you want to save a few keystrokes. 22 | It wraps the list with UL and LI tags with a provided name and also set `Flowpack.Listable:ContentCaseShort` as a default for itemRenderer. 23 | 24 | Configuration options: 25 | 26 | | Setting | Description | Defaults | 27 | |---------|-------------|----------| 28 | | collection | An instance of `ElasticSearchQueryBuilder`, `FlowQuery` object or an `array` of nodes | 'to-be-set' | 29 | | listClass | Classname of UL tag | '' | 30 | | itemClass | Classname of LI tag wrapping each item | '' | 31 | | itemRenderer | Object used for rendering child items. Within it you get two context vars set: `node` and `iterator` | 'Flowpack.Listable:ContentCaseShort' | 32 | | itemName | Name of the the node context variable | 'node' | 33 | | iterationName | Name of the the iterator context variable | 'iteration' | 34 | 35 | Example: 36 | 37 | ``` 38 | prototype(My.Custom:Object) < prototype(Flowpack.Listable:Collection) { 39 | collection = ${q(site).find('[instanceof Something.Custom:Here]').sort('date', 'DESC').slice(0, 6).get()} 40 | listClass = 'MyList' 41 | itemClass = 'MyList-item' 42 | } 43 | ``` 44 | 45 | It would use the object `Something.Custom:Here.Short` for rendering each item. 46 | 47 | Make sure to correctly configure the cache. 48 | 49 | ## Flowpack.Listable:PaginatedCollection 50 | 51 | This object allows you to paginate either **ElasticSearch** results, FlowQuery result objects or pure Node arrays. 52 | 53 | Configuration options: 54 | 55 | | Setting | Description | Defaults | 56 | |---------|-------------|----------| 57 | | collection | An instance of `ElasticSearchQueryBuilder`, `FlowQuery` object or an `array` of nodes | 'to-be-set' | 58 | | itemsPerPage | Number of items per page when using pagination | 24 | 59 | | maximumNumberOfLinks | Number of page links in pagination | 15 | 60 | | listRenderer | Object used for rendering the list. | 'Flowpack.Listable:Collection' | 61 | | showPreviousNextLinks| Boolean value used to decide whether the previous and next links should be added| false | 62 | 63 | When used with ElasticSearch, build the query, but don't execute it, the object will do it for you: 64 | 65 | ``` 66 | prototype(My.Custom:Object) < prototype(Flowpack.Listable:PaginatedCollection) { 67 | collection = ${Search.query(site).nodeType('Something.Custom:Here').sortDesc('date')} 68 | itemsPerPage = 12 69 | prototype(Flowpack.Listable:Collection) { 70 | listClass = 'MyPaginatedList' 71 | itemClass = 'MyPaginatedList-item' 72 | } 73 | } 74 | ``` 75 | 76 | If you have additional URL parameters (e.g for a date filter) you have to register the argument and change the cache entryDiscriminator in order work accordingly. 77 | HINT: Do not forget to register a corresponding route for your custom argument. 78 | 79 | ``` 80 | prototype(My.Custom:Object) < prototype(Flowpack.Listable:PaginatedCollection) { 81 | ... 82 | 83 | prototype(Flowpack.Listable:PaginationParameters) { 84 | date = ${request.arguments.data} 85 | } 86 | 87 | @cache { 88 | entryDiscriminator = ${request.arguments.currentPage + request.arguments.date} 89 | } 90 | 91 | } 92 | ``` 93 | 94 | This object is configured by default to `dynamic` cache mode for pagination to work. All you have to do is add correct `entryTags` and you are all set. 95 | 96 | ## Flowpack.Listable:List 97 | 98 | There's often a need to render a list with a header and an archive link. 99 | This object takes your list and wraps it with just that. 100 | 101 | Configuration options: 102 | 103 | | Setting | Description | Defaults | 104 | |---------|-------------|----------| 105 | | wrapClass | Class of the div that wraps the whole object | '' | 106 | | listTitle | Title of the list | '' | 107 | | listTitleClass | Class of the list title | '' | 108 | | archiveLink | Nodepath for the archive link | '' | 109 | | archiveLinkTitle | Title of the archive link | '' | 110 | | archiveLinkClass | Classname of the archive link | '' | 111 | | archiveLinkAdditionalParams | AdditionalParams of the archive link, e.g. `@context.archiveLinkAdditionalParams = ${{archive: 1}}` | {} | 112 | | list | A list that this object should wrap | `value` | 113 | 114 | Example: 115 | 116 | ``` 117 | prototype(My.Custom:Object) < prototype(Flowpack.Listable:PaginatedCollection) { 118 | @process.list = Flowpack.Listable:List { 119 | listTitle = 'My List' 120 | archiveLink = '~/page-path-or-identifier' 121 | archiveLinkTitle = 'See all news' 122 | } 123 | collection = ${q(site).find('[instanceof Something.Custom:Here]').sort('date', 'DESC').slice(0, 6).get()} 124 | } 125 | ``` 126 | 127 | ## Flowpack.Listable:Pagination 128 | 129 | You can also use pagination standalone from the `PaginatedCollection`. 130 | 131 | Configuration options: 132 | 133 | | Setting | Description | Defaults | 134 | |---------|-------------|----------| 135 | | totalCount | A total count of items | 'to-be-set' | 136 | | itemsPerPage | Number of items per page | 24 | 137 | | maximumNumberOfLinks | A maximum number of links | 15 | 138 | | class | A class around pagination | 'Pagination' | 139 | | itemClass | A total count of items | 'Pagination-item' | 140 | | currentItemClass | A class for a current item | 'isCurrent' | 141 | | currentPage | Current page, starting with 1 | `${request.arguments.currentPage || 1}` | 142 | | showPreviousNextLinks| Boolean value used to decide whether the previous and next links should be added| false | 143 | 144 | # FlowQuery Helpers you can use 145 | 146 | ## filterByDate 147 | 148 | Filter nodes by properties of type date. 149 | 150 | ## sortRecursiveByIndex 151 | 152 | Sort nodes by their position in the node tree. Please use with care, as this can become a very expensive operation, if you operate on bigger subtrees. 153 | 154 | Example: 155 | 156 | ${q(node).children("main").sortRecursiveByIndex('DESC').get()} 157 | --------------------------------------------------------------------------------