Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

introduce catamorphism for ui components #8812

Open
wants to merge 1 commit into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions components/ILIAS/UI/src/Component/Component.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,32 @@ interface Component
* Get the canonical name of the component.
*/
public function getCanonicalName(): string;

/**
* This implements the catamorphism (https://en.wikipedia.org/wiki/Catamorphism)
* for components, which is a clever way to implement a generalized fold over
* data structures.
*
* The scheme starts at the leaves of the structure and applys the function to
* each leave and moves up the tree recursively. The return value of the function
* is put into the "sub structure" to be consumed when the function is applied
* to the upper levels. By using this method, the structure can be broken down
* completely or it can be modified.
*/
public function foldWith(callable $f): mixed;

/**
* This contains the sub structure of the component to support `foldWith`. For
* pristine Components, it shall return all Components that are contained in
* the component. When applying `foldWith` it will contain the results of the
* function for these sub components. A component might contain no substructure
* whatsoever, hence this might return null;
*
* Implementations of Component shall simply pass back their sub components,
* and, most probably, use the implementation of foldWith from the trait
* ComponentHelper and overwrite "getSubComponents" according to their requirements.
*
* @return ?array<mixed>
*/
public function getSubStructure(): ?array;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
<?php

declare(strict_types=1);

/**
* This file is part of ILIAS, a powerful learning management system
* published by ILIAS open source e-Learning e.V.
Expand All @@ -18,11 +16,14 @@
*
*********************************************************************/

declare(strict_types=1);

namespace ILIAS\UI\Implementation\Component;

use ILIAS\UI\Component\Signal;
use InvalidArgumentException;
use Closure;
use ILIAS\UI\Component\Component;

/**
* Provides common functionality for component implementations.
Expand Down Expand Up @@ -239,4 +240,51 @@ protected function wrongTypeMessage(string $expected, $value): string
return "expected $expected, got $type";
}
}

// Implementation for foldWith

private ?array $sub_structure = null;

public function foldWith(callable $f): mixed
{
$clone = clone $this;

$sub_components = $clone->getSubComponents();
if ($sub_components !== null) {
$clone->sub_structure = array_map(
fn($c) => $c->foldWith($f),
$sub_components
);
}

try {
return $f($clone);
} finally {
// Reset substructure afterwards, so transient components will have
// expected component sub structure afterwards.
$clone->sub_structure = null;
}
}

/**
* Get all components that are contained within this component. Sub components
* could either be contained by construction (like a bulky button gets a glyph
* when it is created) or by internal composition (like a launcher uses a bulky
* button internally).
*
* Defaults to empty array, as many components do not contain any substructure.
*/
protected function getSubComponents(): ?array
{
return null;
}


public function getSubStructure(): ?array
{
if ($this->sub_structure === null) {
return $this->getSubComponents();
}
return $this->sub_structure;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,9 @@ protected function setInputGroup(C\Input\Group $input_group): void
* since different containers may allow different request methods.
*/
abstract protected function extractRequestData(ServerRequestInterface $request): InputData;

public function getSubComponents(): array
{
return $this->getInputs();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,9 @@ protected function isClientSideValueOk($value): bool
{
return $this->_isClientSideValueOk($value);
}

protected function getSubComponents(): ?array
{
return $this->getInputs();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace ILIAS\UI\examples\Input\Container\Form\Standard;

/**
* ---
* description: >
* Example showing catamorphism with Form to factor out classes and structure as JSON.
*
* expected output: >
* ILIAS shows a JSON like that:
* {
* "Standard Form Container Input": [
* {
* "Section Field Input": [
* "Text Field Input"
* ]
* },
* {
* "Section Field Input": [
* "Text Field Input"
* ]
* },
* "Text Field Input"
* ]
* }
* ---
*/
function catamorph()
{
global $DIC;
$ui = $DIC->ui()->factory();
$renderer = $DIC->ui()->renderer();

$text_input = $ui->input()->field()
->text("Required Input", "User needs to fill this field")
->withRequired(true);

$section = $ui->input()->field()->section(
[$text_input],
"Section with required field",
"The Form should show an explaining hint at the bottom"
);

$form = $ui->input()->container()->form()->standard("", [$section, $section, $text_input]);

$array = $form->foldWith(
function ($c) {
$subs = $c->getSubStructure();
if ($subs !== null) {
return [$c->getCanonicalName() => $subs];
} else {
return $c->getCanonicalName();
}
}
);

return $renderer->render(
$ui->legacy()->content('<pre>' . print_r(json_encode($array, JSON_PRETTY_PRINT), true) . '</pre>')
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

namespace ILIAS\UI\examples\Input\Container\ViewControl\Standard;

use ILIAS\Data\Order;
use ILIAS\UI\Implementation\Component\Input\ViewControl\Pagination;

/**
* ---
* expected output: >
* ILIAS shows a JSON like that:
* {
* "Standard View Control Container Input": [
* "Pagination View Control Input",
* "Sortation View Control Input",
* "Field Selection View Control Input"
* ]
* }
* ---
*/
function catamorph()
{
global $DIC;
$f = $DIC->ui()->factory();
$r = $DIC->ui()->renderer();

$vcs = [
$f->input()->viewControl()->pagination(),
$f->input()->viewControl()->sortation([
'Field 1, ascending' => new Order('field1', 'ASC'),
'Field 1, descending' => new Order('field1', 'DESC'),
'Field 2, descending' => new Order('field2', 'ASC')
]),
$f->input()->viewControl()->fieldSelection([
'field1' => 'Feld 1',
'field2' => 'Feld 2'
], 'shown columns', 'apply'),
];

$vc_container = $f->input()->container()->viewControl()->standard($vcs);


$array = $vc_container->foldWith(
function ($c) {
$subs = $c->getSubStructure();
if ($subs !== null) {
return [$c->getCanonicalName() => $subs];
} else {
return $c->getCanonicalName();
}
}
);

return $r->render([
$f->legacy()->content('<pre>' . print_r(json_encode($array, JSON_PRETTY_PRINT), true) . '</pre>')
]);
}
3 changes: 3 additions & 0 deletions components/ILIAS/UI/tests/Base.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
use ILIAS\UI\Implementation\Component\SignalGenerator;
use PHPUnit\Framework\MockObject\MockObject;
use ILIAS\UI\Component\Component;
use ILIAS\UI\Implementation\Component\ComponentHelper;
use ILIAS\Data\Factory as DataFactory;
use ILIAS\UI\HelpTextRetriever;
use ILIAS\UI\Help;
Expand Down Expand Up @@ -319,6 +320,8 @@ class SignalGeneratorMock extends SignalGenerator

class DummyComponent implements IComponent
{
use ComponentHelper;

public function getCanonicalName(): string
{
return "DummyComponent";
Expand Down
92 changes: 91 additions & 1 deletion components/ILIAS/UI/tests/Component/ComponentHelperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@

use PHPUnit\Framework\TestCase;
use ILIAS\UI\Implementation\Component\ComponentHelper;
use ILIAS\UI\Component\Component;
use ILIAS\UI\Component\Test\TestComponent;

require_once("vendor/composer/vendor/autoload.php");

require_once(__DIR__ . "/../Renderer/TestComponent.php");

class ComponentMock
class ComponentMock implements Component
{
use ComponentHelper;

Expand Down Expand Up @@ -69,6 +70,14 @@ public function _checkArgList(string $which, array &$value, Closure $check, Clos
{
$this->checkArgList($which, $value, $check, $message);
}

public $sub_components = null;
public $random_data;

public function getSubComponents(): ?array
{
return $this->sub_components;
}
}

class Class1
Expand Down Expand Up @@ -281,4 +290,85 @@ public function testCheckArgListNotOk2(): void
return "expected keys of type string and integer values, got ($k => $v)";
});
}

public function testFoldWith()
{
$a = new ComponentMock();
$a->random_data = "A";
$b = new ComponentMock();
$b->random_data = "B";
$c = new ComponentMock();
$c->random_data = "C";
$c->sub_components = [$a, $b];

$f = function ($c) {
$subs = $c->getSubStructure();
if ($subs !== null) {
return [$c->random_data => $subs];
}

return $c->random_data;
};

$res = $c->foldWith($f);

$this->assertEquals(["C" => ["A", "B"]], $res);
}

public function testFoldWithDoesNotModify()
{
$a = new ComponentMock();
$a->random_data = "A";
$b = new ComponentMock();
$b->random_data = "B";
$c = new ComponentMock();
$c->random_data = "C";
$c->sub_components = [$a, $b];

$f = function ($c) {
$c->random_data = strtolower($c->random_data);
$c->sub_components = $c->getSubStructure();
return $c;
};

$c2 = $c->foldWith($f);
[$a2, $b2] = $c2->getSubStructure();

$this->assertNotEquals(spl_object_id($a), spl_object_id($a2));
$this->assertNotEquals(spl_object_id($b), spl_object_id($b2));
$this->assertNotEquals(spl_object_id($c), spl_object_id($c2));

$this->assertEquals("A", $a->random_data);
$this->assertEquals("B", $b->random_data);
$this->assertEquals("C", $c->random_data);
$this->assertEquals([$a, $b], $c->getSubStructure());

$this->assertEquals("a", $a2->random_data);
$this->assertEquals("b", $b2->random_data);
$this->assertEquals("c", $c2->random_data);
}

public function testFoldWithSubStructureIsTransient()
{
$a = new ComponentMock();
$a->random_data = "A";
$b = new ComponentMock();
$b->random_data = "B";
$c = new ComponentMock();
$c->random_data = "C";
$c->sub_components = [$a, $b];

$f = function ($c) {
$subs = $c->getSubStructure();
if ($subs !== null) {
return [$c, [$c->random_data => $subs]];
}

return [$c, $c->random_data];
};

[$res, $_] = $c->foldWith($f);

$this->assertEquals([$a, $b], $res->getSubStructure());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,9 @@ protected function inputMock()
"withOnUpdate",
"appendOnUpdate",
"withResetTriggeredSignals",
"getTriggeredSignals"
"getTriggeredSignals",
"foldWith",
"getSubStructure"
])
->setMockClassName("Mock_InputNo" . ($no++))
->getMock();
Expand Down
Loading
Loading