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

Add Collection for dynamic Forms #71

Open
wants to merge 20 commits into
base: main
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
49 changes: 49 additions & 0 deletions src/Attributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

use ArrayAccess;
use ArrayIterator;
use Closure;
use InvalidArgumentException;
use IteratorAggregate;
use ReflectionFunction;
use Traversable;

use function ipl\Stdlib\get_php_type;
Expand Down Expand Up @@ -509,6 +511,53 @@ public function getIterator(): Traversable
return new ArrayIterator($this->attributes);
}

/**
* Rebind all callbacks that point to `$oldThisId` to `$newThis`
*
* @param int $oldThisId
* @param object $newThis
*/
public function rebind(int $oldThisId, object $newThis): void
{
$this->rebindCallbacks($this->callbacks, $oldThisId, $newThis);
$this->rebindCallbacks($this->setterCallbacks, $oldThisId, $newThis);
}

/**
* Loops over all `$callbacks`, binds them to `$newThis` only where `$oldThisId` matches. The callbacks are
* modified directly on the `$callbacks` reference.
*
* @param callable[] $callbacks
* @param int $oldThisId
* @param object $newThis
*/
private function rebindCallbacks(array &$callbacks, int $oldThisId, object $newThis): void
{
foreach ($callbacks as &$callback) {
if (! $callback instanceof Closure) {
if (is_array($callback) && ! is_string($callback[0])) {
if (spl_object_id($callback[0]) === $oldThisId) {
$callback[0] = $newThis;
}
}

continue;
}

$closureThis = (new ReflectionFunction($callback))
->getClosureThis();

// Closure is most likely static
if ($closureThis === null) {
continue;
}

if (spl_object_id($closureThis) === $oldThisId) {
$callback = $callback->bindTo($newThis);
}
}
}

public function __clone()
{
foreach ($this->attributes as &$attribute) {
Expand Down
12 changes: 12 additions & 0 deletions src/BaseHtmlElement.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ abstract class BaseHtmlElement extends HtmlDocument
/** @var string Tag of element. Set this property in order to provide the element's tag when extending this class */
protected $tag;

/** @var int Holds an ID to identify itself, used to get the ID of the Object for comparison when cloning */
protected $thisRefId;

/**
* Get the attributes of the element
*
Expand All @@ -83,6 +86,8 @@ abstract class BaseHtmlElement extends HtmlDocument
public function getAttributes()
{
if ($this->attributes === null) {
$this->thisRefId = spl_object_id($this);

$default = $this->getDefaultAttributes();
if (empty($default)) {
$this->attributes = new Attributes();
Expand All @@ -105,6 +110,8 @@ public function getAttributes()
*/
public function setAttributes($attributes)
{
$this->thisRefId = spl_object_id($this);

$this->attributes = Attributes::wantAttributes($attributes);

$this->attributeCallbacksRegistered = false;
Expand Down Expand Up @@ -359,6 +366,11 @@ public function __clone()

if ($this->attributes !== null) {
$this->attributes = clone $this->attributes;

// `$this->thisRefId` is the ID to this Object prior of cloning, `$this` is the newly cloned Object
$this->attributes->rebind($this->thisRefId, $this);

$this->thisRefId = spl_object_id($this);
}
}
}
161 changes: 161 additions & 0 deletions src/FormElement/Collection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

namespace ipl\Html\FormElement;

use ipl\Html\Attributes;

/**
* Collection can be used for creating dynamic forms or elements by describing a template which
* will create as many iteration as provided in `populate()`.
*
* Example:
* ```php
* $collection = new Collection('testCollection');
*
* $collection->setAddElement('add_element', [
* 'required' => false,
* 'label' => 'Add Trigger',
* 'options' => [null => 'Please choose', 'first' => 'First Option'],
* 'class' => 'autosubmit'
* ]);
*
* $collection->onAssembleGroup(function ($group, $addElement, $removeElement) {
* $group->addElement($addElement);
* $group->addElement('input', 'test_input');
* });
*
* $form
* ->registerElement($collection)
* ->addHtml($collection)
* ```
*/
class Collection extends FieldsetElement
{
protected const GROUP_CSS_CLASS = 'form-element-collection';

/** @var callable */
protected $onAssembleGroup;

/** @var array */
protected $addElement = [
'type' => null,
'name' => null,
'options' => null
];

/** @var array */
protected $removeElement = [
'type' => null,
'name' => null,
'options' => null
];

/** @var string[] */
TAINCER marked this conversation as resolved.
Show resolved Hide resolved
protected $defaultAttributes = [
'class' => 'collection'
];

/**
* @param callable $callback
*
* @return void
*/
public function onAssembleGroup(callable $callback): void
{
$this->onAssembleGroup = $callback;
}

/**
* @param string $typeOrElement
* @param string|null $name
* @param null $options
*
* @return $this
*/
public function setAddElement(string $typeOrElement, string $name = null, $options = null): self
{
$this->addElement = ['type' => $typeOrElement, 'name' => $name, 'options' => $options];

return $this;
}

/**
* @param string $typeOrElement
* @param string|null $name
* @param null $options
*
* @return $this
*/
public function setRemoveElement(string $typeOrElement, string $name = null, $options = null): self
{
$this->removeElement = ['type' => $typeOrElement, 'name' => $name, 'options' => $options];

return $this;
}

/**
* @param $group
* @param $addElement
* @param $removeElement
*
* @return $this
*/
protected function assembleGroup($group, $addElement, $removeElement): self
{
if (is_callable($this->onAssembleGroup)) {
call_user_func($this->onAssembleGroup, $group, $addElement, $removeElement);
}

return $this;
}

protected function assemble()
TAINCER marked this conversation as resolved.
Show resolved Hide resolved
{
$values = $this->getPopulatedValues();

$valid = true;
foreach ($values as $key => $items) {
if ($this->removeElement !== null && isset($items[0][$this->removeElement['name']])) {
continue;
}

$group = $this->addGroup($key);

if (empty($group->getValue($this->addElement['name']))) {
$valid = false;
}
}

if ($valid) {
$lastKey = $values ? key(array_slice($values, -1, 1, true)) + 1 : 0;
$this->addGroup($lastKey);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be just $this->addGroup($key ? ($key + 1): 0);?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is getting the last key in the first place. Since this is not the foreach anymore. Functionality wise this should act like array_key_last, which is only available in 7.3 and up.

}
}

protected function addGroup($key): FieldsetElement
{
$group = new FieldsetElement(
$key,
Attributes::create(['class' => static::GROUP_CSS_CLASS])
);

$this
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use the following:

        $this
            ->assembleGroup($group, $addElement ?? null, $removeElement ?? null)
            ->addElement($group);

Else the decorate() call is missing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With addElememt() the items added from assembleGroup are no longer added. I tried adding ->decorate($group) before the addHtml call, and the output was the same in my test cases, also in the Unit Tests.

->registerElement($group)
->assembleGroup(
$group,
$this->addElement['type'] ? $this->createElement(
$this->addElement['type'],
$this->addElement['name'],
$this->addElement['options']
) : null,
$this->removeElement['type'] ? $this->createElement(
$this->removeElement['type'],
$this->removeElement['name'],
$this->removeElement['options']
) : null
)
->addHtml($group);

return $group;
}
}
10 changes: 10 additions & 0 deletions src/FormElement/FormElements.php
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,16 @@ public function getPopulatedValue($name, $default = null)
: $default;
}

/**
* Get all populated values of the element
*
* @return array
*/
public function getPopulatedValues(): array
{
return $this->populatedValues;
}

/**
* Clear populated value of the given element
*
Expand Down
43 changes: 42 additions & 1 deletion src/FormElement/SelectElement.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace ipl\Html\FormElement;

use InvalidArgumentException;
use ipl\Html\Attributes;
use ipl\Html\Common\MultipleAttribute;
use ipl\Html\Html;
Expand Down Expand Up @@ -235,4 +234,46 @@ protected function registerAttributeCallbacks(Attributes $attributes)

$this->registerMultipleAttributeCallback($attributes);
}

private function parseOptionFromContent($content): array
{
$result = [];

if ($content->getTag() === 'optgroup') {
$label = $content->getAttributes()->get('label')->getValue();

foreach ($content->getContent() as $item) {
$result[$label][$item->getValue()] = $item->getLabel();
}

return $result;
}

/** @var SelectOption $content */
$result[$content->getValue()] = $content->getLabel();

return $result;
}

public function __clone()
{
foreach ($this->options as &$option) {
$option = clone $option;
}

$rawOptions = [];
foreach ($this->optionContent as $content) {
if (is_array($content)) {
foreach ($content as $contentEntry) {
$rawOptions += $this->parseOptionFromContent($contentEntry);
}
}

$rawOptions += $this->parseOptionFromContent($content);
}

$this->setOptions($rawOptions);

parent::__clone();
}
}
Loading