diff --git a/.woodpecker/code-style.yml b/.woodpecker/code-style.yml new file mode 100644 index 0000000..0eb985f --- /dev/null +++ b/.woodpecker/code-style.yml @@ -0,0 +1,7 @@ +steps: + code-style: + image: git.digital-competence.de/woodpecker-ci/plugin-phpcs + settings: + args: Classes/ Tests/ + when: + - event: [push, pull_request, manual] diff --git a/.woodpecker/functional-tests.yml b/.woodpecker/functional-tests.yml new file mode 100644 index 0000000..e81fadc --- /dev/null +++ b/.woodpecker/functional-tests.yml @@ -0,0 +1,22 @@ +workspace: + base: /woodpecker + path: package + +matrix: + include: + - FLOW_VERSION: 6.3 + PHP_VERSION: 7.4 + - FLOW_VERSION: 7.3 + PHP_VERSION: 7.4 + - FLOW_VERSION: 7.3 + PHP_VERSION: 8.2 + - FLOW_VERSION: 8.2 + PHP_VERSION: 8.2 + +steps: + functional-tests: + image: git.digital-competence.de/woodpecker-ci/plugin-phpunit-flow:${PHP_VERSION} + settings: + flow_version: ${FLOW_VERSION} + when: + - event: [ push, pull_request, manual ] diff --git a/Classes/FormExtensions/SelectAspect.php b/Classes/FormExtensions/SelectAspect.php new file mode 100644 index 0000000..43b87e6 --- /dev/null +++ b/Classes/FormExtensions/SelectAspect.php @@ -0,0 +1,69 @@ +initializeArguments())") + */ + public function introduceRenderFuncArgument(JoinPointInterface $joinPoint): void + { + $proxy = $joinPoint->getProxy(); + if (!($proxy instanceof SelectViewHelper)) { + return; + } + $proxy->registerArgument('renderFunction', 'string', 'callabe to use to render single object'); + } + + /** + * @Flow\After("setting(DigiComp.FluidRenderFunctions.enableAspects.select) && method(Neos\FluidAdaptor\ViewHelpers\Form\SelectViewHelper->validateArguments())") + */ + public function validateRenderFunction(JoinPointInterface $joinPoint): void + { + $proxy = $joinPoint->getProxy(); + if (!($proxy instanceof SelectViewHelper)) { + return; + } + if (!isset($proxy->arguments['renderFunction'])) { + return; + } + $renderFunction = $proxy->viewHelperVariableContainer->get( + RegisterRenderFunctionViewHelper::class, + $proxy->arguments['renderFunction'] + ); + if (!($renderFunction instanceof InvokeRenderFunctionInterface)) { + throw new \InvalidArgumentException( + 'render function with name "' . $proxy->arguments['renderFunction'] . '" has not been registered.', + 1717293038 + ); + } + + $proxy->arguments['renderFunction'] = $renderFunction; + $originalOptions = $proxy->arguments['options']; + if (!\is_iterable($originalOptions)) { + // Validation is left to the original view helper + return; + } + $proxy->arguments['options'] = new GeneratorClosureIterator( + static function () use ($originalOptions, $renderFunction) { + foreach ($originalOptions as $option) { + yield new RenderableProxy($renderFunction, $option); + } + } + ); + } +} diff --git a/Classes/FormExtensions/TextfieldAspect.php b/Classes/FormExtensions/TextfieldAspect.php new file mode 100644 index 0000000..a116e64 --- /dev/null +++ b/Classes/FormExtensions/TextfieldAspect.php @@ -0,0 +1,59 @@ +initializeArguments())") + */ + public function introduceRenderFuncArgument(JoinPointInterface $joinPoint): void + { + $proxy = $joinPoint->getProxy(); + if (!($proxy instanceof TextfieldViewHelper)) { + return; + } + $proxy->registerArgument('renderFunction', 'string', 'callabe to use to render single object'); + } + + /** + * @Flow\Around("setting(DigiComp.FluidRenderFunctions.enableAspects.textfield) && method(Neos\FluidAdaptor\ViewHelpers\Form\TextfieldViewHelper->getValueAttribute())") + */ + public function applyRenderFunctionToValue(JoinPointInterface $joinPoint) + { + $proxy = $joinPoint->getProxy(); + if (!($proxy instanceof TextfieldViewHelper)) { + return; + } + if (!isset($proxy->arguments['renderFunction'])) { + return $joinPoint->getAdviceChain()->proceed($joinPoint); + } + $renderFunction = $proxy->viewHelperVariableContainer->get( + RegisterRenderFunctionViewHelper::class, + $proxy->arguments['renderFunction'] + ); + if (!($renderFunction instanceof InvokeRenderFunctionInterface)) { + throw new \InvalidArgumentException( + 'render function with name "' . $proxy->arguments['renderFunction'] . '" has not been registered.', + 1717293038 + ); + } + $originalObject = $joinPoint->getAdviceChain()->proceed($joinPoint); + if (\is_object($originalObject)) { + return new RenderableProxy($renderFunction, $originalObject); + } + return $originalObject; + } +} diff --git a/Classes/InvokeRenderFunctionInterface.php b/Classes/InvokeRenderFunctionInterface.php new file mode 100644 index 0000000..01554e2 --- /dev/null +++ b/Classes/InvokeRenderFunctionInterface.php @@ -0,0 +1,10 @@ +closure = $closure; + } + + public function getIterator(): \Generator + { + return ($this->closure)(); + } +} diff --git a/Classes/Utils/NodeRenderTransfer.php b/Classes/Utils/NodeRenderTransfer.php new file mode 100644 index 0000000..213c0be --- /dev/null +++ b/Classes/Utils/NodeRenderTransfer.php @@ -0,0 +1,51 @@ +node = $node; + $this->subjectName = $subjectName; + } + + public function __invoke($object): string + { + // @deprecated: drop the condition, if compatibility to Flow 6.3 can be dropped + $reflector = new \ReflectionClass(RenderingContext::class); + if ($reflector->implementsInterface(ProxyInterface::class)) { + try { + $reflector = new \ReflectionClass($reflector->getParentClass()->getName()); + } catch (\Exception $e) { + // nothing, go with the first one + } + } + $constructor = $reflector->getConstructor(); + + if ($constructor->getNumberOfRequiredParameters() > 0) { + $context = new RenderingContext(new DummyView()); + } else { + $context = new RenderingContext(); + } + $context->getVariableProvider()->add($this->subjectName, $object); + return \trim($this->node->evaluate($context)); + } +} diff --git a/Classes/Utils/RenderableProxy.php b/Classes/Utils/RenderableProxy.php new file mode 100644 index 0000000..1540dcc --- /dev/null +++ b/Classes/Utils/RenderableProxy.php @@ -0,0 +1,39 @@ +renderFunction = $renderFunction; + $this->object = $object; + try { + $this->Persistence_Object_Identifier = + ObjectAccess::getProperty($object, 'Persistence_Object_Identifier', true); + } catch (PropertyNotAccessibleException $e) { + // ok. fine + } + } + + public function __toString(): string + { + $render = $this->renderFunction; + return $render($this->object); + } +} diff --git a/Classes/ViewHelpers/ApplyRenderFunctionViewHelper.php b/Classes/ViewHelpers/ApplyRenderFunctionViewHelper.php new file mode 100644 index 0000000..02539a1 --- /dev/null +++ b/Classes/ViewHelpers/ApplyRenderFunctionViewHelper.php @@ -0,0 +1,57 @@ +registerArgument('in', 'mixed', 'subject to apply the render function to'); + $this->registerArgument('function', 'string', 'render function to use', true); + $this->registerArgument( + 'force', + 'bool', + 'if set, it will be applied to the provided in, if not, it will be applied to each item for ' + . 'iterables instead', + false, + false + ); + } + + public function validateArguments() + { + parent::validateArguments(); + $this->validateRenderFunctionArgument('function'); + } + + public function render() + { + $in = $this->arguments['in']; + if ($in === null) { + $in = $this->renderChildren(); + } + if (\is_iterable($in) && $this->arguments['force'] === false) { + return new GeneratorClosureIterator(fn () => $this->getProxyGenerator($this->arguments['function'], $in)); + } else { + return new RenderableProxy($this->arguments['function'], $in); + } + } + + protected function getProxyGenerator(InvokeRenderFunctionInterface $function, iterable $objects): \Generator + { + foreach ($objects as $object) { + yield new RenderableProxy($function, $object); + } + } +} diff --git a/Classes/ViewHelpers/RegisterRenderFunctionViewHelper.php b/Classes/ViewHelpers/RegisterRenderFunctionViewHelper.php new file mode 100644 index 0000000..511cae7 --- /dev/null +++ b/Classes/ViewHelpers/RegisterRenderFunctionViewHelper.php @@ -0,0 +1,55 @@ +registerArgument('as', 'string', 'Name of the registered render function', false, 'renderFunc'); + $this->registerArgument( + 'subjectName', + 'string', + 'Name of the argument passed to render function', + false, + 'subject' + ); + } + + public function compile( + $argumentsName, + $closureName, + &$initializationPhpCode, + ViewHelperNode $node, + TemplateCompiler $compiler + ) { + // we disable compiling, because we will need access to the AST, which is not available if compiled + // a cool improvement would be to drop the need to the AST and so become compilable + $compiler->disable(); + return "''"; + } + + public function render(): string + { + $transferNode = new RootNode(); + foreach ($this->childNodes as $childNode) { + $transferNode->addChildNode($childNode); + } + $renderer = new NodeRenderTransfer($transferNode, $this->arguments['subjectName']); + $this->renderingContext->getViewHelperVariableContainer() + ->add(static::class, $this->arguments['as'], $renderer); + return ''; + } +} diff --git a/Classes/ViewHelpers/Traits/ValidateRenderFunctionTrait.php b/Classes/ViewHelpers/Traits/ValidateRenderFunctionTrait.php new file mode 100644 index 0000000..c486f4d --- /dev/null +++ b/Classes/ViewHelpers/Traits/ValidateRenderFunctionTrait.php @@ -0,0 +1,26 @@ +viewHelperVariableContainer->get( + RegisterRenderFunctionViewHelper::class, + $this->arguments[$argumentName] + ); + if (!($renderFunction instanceof InvokeRenderFunctionInterface)) { + throw new \InvalidArgumentException( + 'render function with name "' . $this->arguments[$argumentName] . '" has not been registered.', + 1717293038 + ); + } + $this->arguments[$argumentName] = $renderFunction; + } +} diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml new file mode 100644 index 0000000..763eab0 --- /dev/null +++ b/Configuration/Settings.yaml @@ -0,0 +1,5 @@ +DigiComp: + FluidRenderFunctions: + enableAspects: + select: true + textfield: true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7c439ae --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024 Ferdinand Kuhl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5cdbaf6 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# DigiComp.FluidRenderFunctions + +## Quickstart + +This Package provides you with the possibility to register render functions, created from them, to use them dynamically else where. + +Let me show you the idea: +```html + +``` +Assuming you know how the SelectViewHelper works, you know, you can provide an "optionLabelField"-argument to adivce the ViewHelper to use a property of your options. +But, what if you want to use a complete template, to display your books? +FluidRenderFunctions to the rescue: +```html + + {myBook.name} from {myBook.author.name} + + +``` + +To make your live easier, FluidRenderFunctions augments the original `SelectViewHelper` and the `TextfieldViewHelper` with an optional `renderFunction` argument. That way, you can even use the usual Textfield to display formatted Datetime objects. Neat! + +## Configuration + +If - for whatever reason - you do not want FluidRenderFunctions to augment the original ViewHelpers you can opt out by setting `DigiComp.FluidRenderFunctions.enableAspects.select` or `DigiComp.FluidRenderFunctions.enableAspects.textfield` to `false`. diff --git a/Resources/Private/Layouts/Test.html b/Resources/Private/Layouts/Test.html new file mode 100644 index 0000000..24f730c --- /dev/null +++ b/Resources/Private/Layouts/Test.html @@ -0,0 +1 @@ + diff --git a/Resources/Private/Templates/Tests/Functional/Fixtures/Test/ExtendedSelect.html b/Resources/Private/Templates/Tests/Functional/Fixtures/Test/ExtendedSelect.html new file mode 100644 index 0000000..185c717 --- /dev/null +++ b/Resources/Private/Templates/Tests/Functional/Fixtures/Test/ExtendedSelect.html @@ -0,0 +1,6 @@ +{namespace rf=DigiComp\FluidRenderFunctions\ViewHelpers} + + + {subject.name} is cool + + diff --git a/Resources/Private/Templates/Tests/Functional/Fixtures/Test/ExtendedTextfield.html b/Resources/Private/Templates/Tests/Functional/Fixtures/Test/ExtendedTextfield.html new file mode 100644 index 0000000..548c86e --- /dev/null +++ b/Resources/Private/Templates/Tests/Functional/Fixtures/Test/ExtendedTextfield.html @@ -0,0 +1,11 @@ +{namespace rf=DigiComp\FluidRenderFunctions\ViewHelpers} + + + {subject -> f:format.date(format: 'd.m.Y')} + + + + + + + diff --git a/Resources/Private/Templates/Tests/Functional/Fixtures/Test/Index.html b/Resources/Private/Templates/Tests/Functional/Fixtures/Test/Index.html new file mode 100644 index 0000000..ab764ae --- /dev/null +++ b/Resources/Private/Templates/Tests/Functional/Fixtures/Test/Index.html @@ -0,0 +1,8 @@ + +{namespace rf=DigiComp\FluidRenderFunctions\ViewHelpers} + + + {subject.name} is cool + + {test -> rf:applyRenderFunction(function: 'testFunc', force: true)} + diff --git a/Resources/Private/Templates/Tests/Functional/Fixtures/Test/Select.html b/Resources/Private/Templates/Tests/Functional/Fixtures/Test/Select.html new file mode 100644 index 0000000..120ca49 --- /dev/null +++ b/Resources/Private/Templates/Tests/Functional/Fixtures/Test/Select.html @@ -0,0 +1,8 @@ + +{namespace rf=DigiComp\FluidRenderFunctions\ViewHelpers} + + + {subject.name} is cool + + + diff --git a/Tests/Functional/Fixtures/Controller/TestController.php b/Tests/Functional/Fixtures/Controller/TestController.php new file mode 100644 index 0000000..16238ae --- /dev/null +++ b/Tests/Functional/Fixtures/Controller/TestController.php @@ -0,0 +1,34 @@ +view->assign('test', ['name' => 'hallo']); + } + + public function selectAction() + { + $this->view->assign('testEntities', (new Query(Tag::class))->execute()); + } + + public function extendedSelectAction() + { + $this->view->assign('tags', (new Query(Tag::class))->execute()); + } + + public function extendedTextfieldAction() + { + $this->view->assign('now', new \DateTimeImmutable('2024-06-02T15:03:00Z')); + $this->view->assign('post', (new Query(Post::class))->execute()->getFirst()); + } +} diff --git a/Tests/Functional/Fixtures/Domain/Post.php b/Tests/Functional/Fixtures/Domain/Post.php new file mode 100644 index 0000000..6d18f51 --- /dev/null +++ b/Tests/Functional/Fixtures/Domain/Post.php @@ -0,0 +1,43 @@ +title = $title; + $this->publishedAt = $publishedAt; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function getPublishedAt(): ?\DateTimeImmutable + { + return $this->publishedAt; + } +} diff --git a/Tests/Functional/RenderFunctionsTest.php b/Tests/Functional/RenderFunctionsTest.php new file mode 100644 index 0000000..65f633c --- /dev/null +++ b/Tests/Functional/RenderFunctionsTest.php @@ -0,0 +1,98 @@ +setUriPattern('test/fluidrenderfunctions/test(/{@action})'); + $route->setDefaults([ + '@package' => 'DigiComp.FluidRenderFunctions', + '@subpackage' => 'Tests\Functional\Fixtures', + '@controller' => 'Test', + '@action' => 'index', + ]); + $route->setAppendExceedingArguments(true); + $this->router->addRoute($route); + } + + /** + * @test + */ + public function itAllowsFluidToRenderWrappedArray(): void + { + $response = $this->browser->request('http://localhost/test/fluidrenderfunctions/test'); + static::assertEquals("hallo is cool\n", (string)$response->getBody()); + } + + /** + * @test + */ + public function itAllowsToRenderTheOptionsArgumentOfSelectNicely(): void + { + $post1 = new Tag('hallo'); + $this->persistenceManager->add($post1); + $post2 = new Tag('hallo 2'); + $this->persistenceManager->add($post2); + $this->persistenceManager->persistAll(); + + $this->browser->request('http://localhost/test/fluidrenderfunctions/test/select'); + $options = $this->browser->getCrawler() + ->filterXPath('//select[1]/option') + ->each(fn (Crawler $node) => $node->text()); + static::assertEquals(['hallo is cool', 'hallo 2 is cool'], $options); + } + + /** + * @test + */ + public function itAllowsRenderFunctionOnStandardSelect(): void + { + $post1 = new Tag('hallo'); + $this->persistenceManager->add($post1); + $post2 = new Tag('hallo 2'); + $this->persistenceManager->add($post2); + $this->persistenceManager->persistAll(); + + $this->browser->request('http://localhost/test/fluidrenderfunctions/test/extendedselect'); + $options = $this->browser->getCrawler() + ->filterXPath('//select[1]/option') + ->each(fn (Crawler $node) => $node->text()); + static::assertEquals(['hallo is cool', 'hallo 2 is cool'], $options); + } + + /** + * @test + */ + public function itAllowsRenderFunctionOnStandardTextField(): void + { + $testDate = new \DateTimeImmutable('2024-06-02T15:03:00Z'); + + $post1 = new Post('hallo', $testDate); + $this->persistenceManager->add($post1); + $this->persistenceManager->persistAll(); + + $this->browser->request('http://localhost/test/fluidrenderfunctions/test/extendedtextfield'); + $input1 = $this->browser->getCrawler()->filterXPath('//input[@type="text"][1]/@value')->text('input not found'); + static::assertEquals('02.06.2024', $input1); + $input2 = $this->browser->getCrawler()->filterXPath('//input[@type="text"][2]/@value')->text('input not found'); + static::assertEquals('02.06.2024', $input2); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4e1cc21 --- /dev/null +++ b/composer.json @@ -0,0 +1,46 @@ +{ + "name": "digicomp/fluid-render-functions", + "description": "define fluid based render functions and use them else where", + "type": "neos-package", + "require": { + "neos/flow": "^6.3.5 | ^7.3 | ^8.3", + "neos/fluid-adaptor": "^6.3.5 | ^7.3 | ^8.3", + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "~8.5" + }, + "autoload": { + "psr-4": { + "DigiComp\\FluidRenderFunctions\\": "Classes/" + } + }, + "autoload-dev": { + "psr-4": { + "DigiComp\\FluidRenderFunctions\\Tests\\": "Tests/" + } + }, + "extra": { + "neos": { + "package-key": "DigiComp.FluidRenderFunctions" + }, + "branch-alias": { + "dev-develop": "1.0.x-dev" + } + }, + "authors": [ + { + "name": "Ferdinand Kuhl", + "email": "f.kuhl@digital-competence.de", + "homepage": "https://www.digital-competence.de", + "role": "Developer" + } + ], + "license": "MIT", + "homepage": "https://git.digital-competence.de/Packages/DigiComp.FluidRenderFunctions", + "keywords": [ + "Neos", + "Flow", + "fluid" + ] +}