Skip to content

Commit

Permalink
Support for form objects
Browse files Browse the repository at this point in the history
  • Loading branch information
calebporzio committed May 17, 2023
1 parent f80567a commit b349098
Show file tree
Hide file tree
Showing 14 changed files with 299 additions and 49 deletions.
3 changes: 0 additions & 3 deletions js/lifecycle.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { monkeyPatchDomSetAttributeToAllowAtSymbols } from 'utils'
import { closestComponent, initComponent } from './store'
import { initDirectives } from './directives'
import { trigger } from './events'
Expand All @@ -8,8 +7,6 @@ import morph from '@alpinejs/morph'
import Alpine from 'alpinejs'

export function start() {
monkeyPatchDomSetAttributeToAllowAtSymbols()

Alpine.plugin(morph)
Alpine.plugin(history)
Alpine.plugin(intersect)
Expand Down
23 changes: 0 additions & 23 deletions js/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,29 +25,6 @@ export class WeakBag {
each(key, callback) { return this.get(key).forEach(callback) }
}

export function monkeyPatchDomSetAttributeToAllowAtSymbols() {
// Because morphdom may add attributes to elements containing "@" symbols
// like in the case of an Alpine `@click` directive, we have to patch
// the standard Element.setAttribute method to allow this to work.
let original = Element.prototype.setAttribute

let hostDiv = document.createElement('div')

Element.prototype.setAttribute = function newSetAttribute(name, value) {
if (! name.includes('@')) {
return original.call(this, name, value)
}

hostDiv.innerHTML = `<span ${name}="${value}"></span>`

let attr = hostDiv.firstElementChild.getAttributeNode(name)

hostDiv.firstElementChild.removeAttributeNode(attr)

this.setAttributeNode(attr)
}
}

export function dispatch(el, name, detail = {}, bubbles = true) {
el.dispatchEvent(
new CustomEvent(name, {
Expand Down
2 changes: 1 addition & 1 deletion src/Drawer/BaseUtils.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ static function getPublicPropertiesDefinedOnSubclass($target) {
});
}

static function getPublicProperties($target, $filter)
static function getPublicProperties($target, $filter = null)
{
return collect((new \ReflectionObject($target))->getProperties())
->filter(function ($property) {
Expand Down
2 changes: 1 addition & 1 deletion src/Features/SupportAttributes/Attribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function getValue()
throw new \Exception('Can\'t set the value of a non-property attribute.');
}

return $this->component->all()[$this->levelName];
return data_get($this->component->all(), $this->levelName);
}

function setValue($value)
Expand Down
16 changes: 8 additions & 8 deletions src/Features/SupportAttributes/AttributeCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,30 @@

class AttributeCollection extends Collection
{
static function fromComponent($target)
static function fromComponent($component, $subTarget = null, $propertyNamePrefix = '')
{
$instance = new static;

$reflected = new ReflectionObject($target);
$reflected = new ReflectionObject($subTarget ?? $component);

foreach ($reflected->getAttributes() as $attribute) {
$instance->push(tap($attribute->newInstance(), function ($attribute) use ($target) {
$attribute->__boot($target, AttributeLevel::ROOT);
$instance->push(tap($attribute->newInstance(), function ($attribute) use ($component) {
$attribute->__boot($component, AttributeLevel::ROOT);
}));
}

foreach ($reflected->getMethods() as $method) {
foreach ($method->getAttributes() as $attribute) {
$instance->push(tap($attribute->newInstance(), function ($attribute) use ($target, $method) {
$attribute->__boot($target, AttributeLevel::METHOD, $method->getName());
$instance->push(tap($attribute->newInstance(), function ($attribute) use ($component, $method, $propertyNamePrefix) {
$attribute->__boot($component, AttributeLevel::METHOD, $propertyNamePrefix . $method->getName());
}));
}
}

foreach ($reflected->getProperties() as $property) {
foreach ($property->getAttributes() as $attribute) {
$instance->push(tap($attribute->newInstance(), function ($attribute) use ($target, $property) {
$attribute->__boot($target, AttributeLevel::PROPERTY, $property->getName());
$instance->push(tap($attribute->newInstance(), function ($attribute) use ($component, $property, $propertyNamePrefix) {
$attribute->__boot($component, AttributeLevel::PROPERTY, $propertyNamePrefix . $property->getName());
}));
}
}
Expand Down
7 changes: 6 additions & 1 deletion src/Features/SupportAttributes/HandlesAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

namespace Livewire\Features\SupportAttributes;

use Attribute;
use ReflectionObject;
use ReflectionAttribute;
use Attribute;

trait HandlesAttributes
{
Expand All @@ -14,4 +14,9 @@ function getAttributes()
{
return $this->attributes ??= AttributeCollection::fromComponent($this);
}

function mergeOutsideAttributes(AttributeCollection $attributes)
{
$this->attributes = $this->getAttributes()->concat($attributes);
}
}
2 changes: 1 addition & 1 deletion src/Features/SupportAttributes/SupportAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function update($propertyName, $fullPath, $newValue)
->getAttributes()
->whereInstanceOf(LivewireAttribute::class)
->filter(fn ($attr) => $attr->getLevel() === AttributeLevel::PROPERTY)
->filter(fn ($attr) => $attr->getName() === $propertyName)
->filter(fn ($attr) => $attr->getName() === $fullPath)
->map(function ($attribute) use ($fullPath, $newValue) {
if (method_exists($attribute, 'update')) {
return $attribute->update($fullPath, $newValue);
Expand Down
56 changes: 56 additions & 0 deletions src/Features/SupportFormObjects/Form.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace Livewire\Features\SupportFormObjects;

use Illuminate\Contracts\Support\Arrayable;
use Livewire\Component;
use Livewire\Drawer\Utils;

class Form implements Arrayable
{
function __construct(
protected Component $component,
protected $propertyName
) {
$this->addValidationRulesToComponent();
}

public function getComponent() { return $this->component; }
public function getPropertyName() { return $this->propertyName; }

public function addValidationRulesToComponent()
{
$rules = [];

if (method_exists($this, 'rules')) $rules = $this->rules();
else if (property_exists($this, 'rules')) $rules = $this->rules;

$rulesWithPrefixedKeys = [];

foreach ($rules as $key => $value) {
$rulesWithPrefixedKeys[$this->propertyName . '.' . $key] = $value;
}

$this->component->addRulesFromOutside($rulesWithPrefixedKeys);
}

public function validate()
{
$rules = $this->component->getRules();

$filteredRules = [];

foreach ($rules as $key => $value) {
if (! str($key)->startsWith($this->propertyName . '.')) continue;

$filteredRules[$key] = $value;
}

return $this->component->validate($filteredRules)[$this->propertyName];
}

public function toArray()
{
return Utils::getPublicProperties($this);
}
}
53 changes: 53 additions & 0 deletions src/Features/SupportFormObjects/FormObjectSynth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace Livewire\Features\SupportFormObjects;

use Livewire\Mechanisms\HandleComponents\Synthesizers\Synth;
use Livewire\Features\SupportAttributes\AttributeCollection;
use Livewire\Drawer\Utils;

class FormObjectSynth extends Synth {
public static $key = 'form';

static function match($target)
{
return $target instanceof Form;
}

function dehydrate($target, $dehydrateChild)
{
$data = $target->toArray();

foreach ($data as $key => $child) {
$data[$key] = $dehydrateChild($key, $child);
}

return [$data, ['class' => get_class($target)]];
}

function hydrate($data, $meta, $hydrateChild)
{
$form = new $meta['class']($this->context->component, $this->path);

static::bootFormObject($this->context->component, $form, $this->path);

foreach ($data as $key => $child) {
$form->$key = $hydrateChild($key, $child);
}

return $form;
}

function set(&$target, $key, $value,)
{
$target->$key = $value;
}

public static function bootFormObject($component, $form, $path)
{
$component->mergeOutsideAttributes(
AttributeCollection::fromComponent($component, $form, $path . '.')
);
}
}

46 changes: 46 additions & 0 deletions src/Features/SupportFormObjects/SupportFormObjects.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

namespace Livewire\Features\SupportFormObjects;

use ReflectionClass;
use Livewire\ComponentHook;
use Livewire\Features\SupportAttributes\AttributeCollection;

class SupportFormObjects extends ComponentHook
{
public static function provide()
{
app('livewire')->propertySynthesizer(
FormObjectSynth::class
);
}

function boot()
{
$this->initializeFormObjects();
}

protected function initializeFormObjects()
{
foreach ((new ReflectionClass($this->component))->getProperties() as $property) {
// Public properties only...
if ($property->isPublic() !== true) continue;
// Uninitialized properties only...
if ($property->isInitialized($this->component)) continue;

$type = $property->getType()->getName();

// "Form" object property types only...
if (! is_subclass_of($type, Form::class)) continue;

$form = new $type(
$this->component,
$name = $property->getName()
);

FormObjectSynth::bootFormObject($this->component, $form, $name);

$property->setValue($this->component, $form);
}
}
}
109 changes: 109 additions & 0 deletions src/Features/SupportFormObjects/UnitTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

namespace Livewire\Features\SupportFormObjects;

use Livewire\Livewire;
use Livewire\Component;
use ReflectionObject;

class UnitTest extends \Tests\TestCase
{
/** @test */
function can_use_a_form_object()
{
Livewire::test(new class extends Component {
public PostFormStub $form;

public function render() {
return '<div></div>';
}
})
->assertSet('form.title', '')
->assertSet('form.content', '')
->set('form.title', 'Some Title')
->set('form.content', 'Some content...')
->assertSet('form.title', 'Some Title')
->assertSet('form.content', 'Some content...')
;
}

/** @test */
function can_validate_a_form_object()
{
Livewire::test(new class extends Component {
public PostFormValidateStub $form;

function save()
{
$this->form->validate();
}

public function render() {
return '<div></div>';
}
})
->assertSet('form.title', '')
->assertSet('form.content', '')
->assertHasNoErrors()
->call('save')
->assertHasErrors('form.title')
->assertHasErrors('form.content')
;
}

/** @test */
function can_validate_a_form_object_using_rule_attributes()
{
Livewire::test(new class extends Component {
public PostFormRuleAttributeStub $form;

function save()
{
$this->form->validate();
}

function render() {
return '<div></div>';
}
})
->assertSet('form.title', '')
->assertSet('form.content', '')
->assertHasNoErrors()
->call('save')
->assertHasErrors('form.title')
->assertHasErrors('form.content')
->set('form.title', 'title...')
->set('form.content', 'content...')
->assertHasNoErrors()
->call('save')
;
}
}

class PostFormStub extends Form
{
public $title = '';

public $content = '';
}

class PostFormValidateStub extends Form
{
public $title = '';

public $content = '';

protected $rules = [
'title' => 'required',
'content' => 'required',
];
}

class PostFormRuleAttributeStub extends Form
{
#[\Livewire\Attributes\Rule('required')]
public $title = '';

#[\Livewire\Attributes\Rule('required')]
public $content = '';
}
Loading

0 comments on commit b349098

Please sign in to comment.