diff --git a/composer.json b/composer.json index ac0622a91..26c7becc9 100644 --- a/composer.json +++ b/composer.json @@ -26,13 +26,14 @@ "fakerphp/faker": "^1.14", "friendsofphp/php-cs-fixer": "^3.0", "inertiajs/inertia-laravel": "dev-master#4508fd1", + "livewire/livewire": "^3.0", "mockery/mockery": "^1.6", "nesbot/carbon": "^2.63", - "nette/php-generator": "^3.5", "nunomaduro/larastan": "^2.0", "orchestra/testbench": "^8.0|^9.0", "pestphp/pest": "^2.31", "pestphp/pest-plugin-laravel": "^2.0", + "pestphp/pest-plugin-livewire": "^2.1", "phpbench/phpbench": "^1.2", "phpstan/extension-installer": "^1.1", "phpunit/phpunit": "^10.0", diff --git a/config/data.php b/config/data.php index 81fae4424..b500f10da 100644 --- a/config/data.php +++ b/config/data.php @@ -1,6 +1,5 @@ [ - /* + /** * Provides default configuration for the `make:data` command. These settings can be overridden with options * passed directly to the `make:data` command for generating single Data classes, or if not set they will * automatically fall back to these defaults. See `php artisan make:data --help` for more information */ 'make' => [ - /* + /** * The default namespace for generated Data classes. This exists under the application's root namespace, * so the default 'Data` will end up as '\App\Data', and generated Data classes will be placed in the * app/Data/ folder. Data classes can live anywhere, but this is where `make:data` will put them. */ 'namespace' => 'Data', - /* + /** * This suffix will be appended to all data classes generated by make:data, so that they are less likely * to conflict with other related classes, controllers or models with a similar name without resorting * to adding an alias for the Data object. Set to a blank string (not null) to disable. @@ -132,4 +131,13 @@ 'suffix' => 'Data', ], ], + + /** + * When using Livewire, the package allows you to enable or disable the synths + * these synths will automatically handle the data objects and their + * properties when used in a Livewire component. + */ + 'livewire' => [ + 'enable_synths' => false, + ], ]; diff --git a/docs/advanced-usage/use-with-livewire.md b/docs/advanced-usage/use-with-livewire.md index 57d528a3a..0c43696af 100644 --- a/docs/advanced-usage/use-with-livewire.md +++ b/docs/advanced-usage/use-with-livewire.md @@ -3,7 +3,8 @@ title: Use with Livewire weight: 10 --- -> Livewire is a full-stack framework for Laravel that makes building dynamic interfaces simple without leaving the comfort of Laravel. +> Livewire is a full-stack framework for Laravel that makes building dynamic interfaces simple without leaving the +> comfort of Laravel. Laravel Data works excellently with [Laravel Livewire](https://laravel-livewire.com). @@ -46,3 +47,83 @@ class SongData extends Data implements Wireable } } ``` + +## Livewire Synths (Experimental) + +Laravel Data also provides a way to use Livewire Synths with your data objects. It will allow you to use data objects +and collections +without the need to make them Wireable. This is an experimental feature and is subject to change. + +You can enable this feature by setting the config option in `data.php`: + +```php +'livewire' => [ + 'enable_synths' => false, +] +``` + +Once enabled, you can use data objects within your Livewire components without the need to make them Wireable: + +```php +class SongUpdateComponent extends Component +{ + public SongData $data; + + public function mount(public int $id): void + { + $this->data = SongData::from(Song::findOrFail($id)); + } + + public function save(): void + { + Artist::findOrFail($this->id)->update($this->data->toArray()); + } + + public function render(): string + { + return <<<'BLADE' +
+

Songs

+ + +

Title: {{ $data->title }}

+

Artist: {{ $data->artist }}

+ +
+ BLADE; + } +} +``` + +### Lazy + +It is possible to use Lazy properties, these properties will not be sent over the wire unless they're included. **Always +include properties permanently** because a data object is being transformed and then cast again between Livewire +requests the includes should be permanent. + +It is possible to query lazy nested data objects, it is however not possible to query lazy properties which are not a data: + +```php +use Spatie\LaravelData\Lazy;class LazySongData extends Data +{ + public function __construct( + public Lazy|ArristData $artist, + public Lazy|string $title, + ) {} +} +``` + +Within your Livewire view + +```php +$this->data->artist->name; // Works +$this->data->title; // Does not work +``` + +### Validation + +Laravel data **does not provide validation** when using Livewire, you should do this yourself! This is because laravel-data +does not support object validation at the moment. Only validating payloads which eventually become data objects. +The validation could technically happen when hydrating the data object, but this is not implemented +because we cannot guarantee that every hydration happens when a user made sure the data is valid +and thus the payload should be validated. diff --git a/src/Concerns/ContextableData.php b/src/Concerns/ContextableData.php index 3feb88482..7af16360e 100644 --- a/src/Concerns/ContextableData.php +++ b/src/Concerns/ContextableData.php @@ -72,4 +72,12 @@ public function getDataContext(): DataContext return $this->_dataContext; } + + public function setDataContext( + DataContext $dataContext + ): static { + $this->_dataContext = $dataContext; + + return $this; + } } diff --git a/src/LaravelDataServiceProvider.php b/src/LaravelDataServiceProvider.php index f748b0eef..b58713847 100644 --- a/src/LaravelDataServiceProvider.php +++ b/src/LaravelDataServiceProvider.php @@ -2,11 +2,14 @@ namespace Spatie\LaravelData; +use Livewire\Livewire; use Spatie\LaravelData\Commands\DataMakeCommand; use Spatie\LaravelData\Commands\DataStructuresCacheCommand; use Spatie\LaravelData\Contracts\BaseData; use Spatie\LaravelData\Support\Caching\DataStructureCache; use Spatie\LaravelData\Support\DataConfig; +use Spatie\LaravelData\Support\Livewire\LivewireDataCollectionSynth; +use Spatie\LaravelData\Support\Livewire\LivewireDataSynth; use Spatie\LaravelData\Support\VarDumper\VarDumperManager; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -50,6 +53,16 @@ function () { fn ($container) => $class::from($container['request']) ); }); + + if(config('data.livewire.enable_synths') && class_exists(Livewire::class)) { + $this->registerLivewireSynths(); + } + } + + protected function registerLivewireSynths(): void + { + Livewire::propertySynthesizer(LivewireDataSynth::class); + Livewire::propertySynthesizer(LivewireDataCollectionSynth::class); } public function packageBooted(): void diff --git a/src/Support/DataClassMorphMap.php b/src/Support/DataClassMorphMap.php index d1e604a7e..8e3d5866f 100644 --- a/src/Support/DataClassMorphMap.php +++ b/src/Support/DataClassMorphMap.php @@ -39,6 +39,9 @@ public function merge(array $map): self return $this; } + /** + * @return class-string|null + */ public function getMorphedDataClass(string $alias): ?string { return $this->map[$alias] ?? null; diff --git a/src/Support/Lazy/LivewireLostLazy.php b/src/Support/Lazy/LivewireLostLazy.php new file mode 100644 index 000000000..2e9452419 --- /dev/null +++ b/src/Support/Lazy/LivewireLostLazy.php @@ -0,0 +1,20 @@ +dataClass}::{$this->propertyName}` was lost when the data object was transformed to be used by Livewire. You can include the property and then the correct value will be set when creating the data object from Livewire again."); + } +} diff --git a/src/Support/Livewire/LivewireDataCollectionSynth.php b/src/Support/Livewire/LivewireDataCollectionSynth.php new file mode 100644 index 000000000..9954fab71 --- /dev/null +++ b/src/Support/Livewire/LivewireDataCollectionSynth.php @@ -0,0 +1,82 @@ +dataConfig = app(DataConfig::class); + + parent::__construct($context, $path); + } + + public static function match($target): bool + { + return is_a($target, DataCollection::class, true); + } + + public function get(&$target, $key): BaseData + { + return $target[$key]; + } + + public function set(&$target, $key, $value) + { + $target[$key] = $value; + } + + /** + * @param callable(array-key, mixed):mixed $dehydrateChild + */ + public function dehydrate(DataCollection $target, callable $dehydrateChild): array + { + $morph = $this->dataConfig->morphMap->getDataClassAlias($target->dataClass) ?? $target->dataClass; + + $payload = []; + + foreach ($target->toCollection() as $key => $child) { + $payload[$key] = $dehydrateChild($key, $child); + } + + return [ + $payload, + [ + 'dataCollectionClass' => $target::class, + 'dataMorph' => $morph, + 'context' => encrypt($target->getDataContext()), + ], + ]; + } + + /** + * @param callable(array-key, mixed):mixed $hydrateChild + */ + public function hydrate($value, $meta, $hydrateChild) + { + $context = decrypt($meta['context']); + $dataCollectionClass = $meta['dataCollectionClass']; + $dataClass = $this->dataConfig->morphMap->getMorphedDataClass($meta['dataMorph']) ?? $meta['dataMorph']; + + foreach ($value as $key => $child) { + $value[$key] = $hydrateChild($key, $child); + } + + /** @var DataCollection $dataCollection */ + $dataCollection = new $dataCollectionClass($dataClass, $value); + + $dataCollection->setDataContext($context); + + return $dataCollection; + } +} diff --git a/src/Support/Livewire/LivewireDataSynth.php b/src/Support/Livewire/LivewireDataSynth.php new file mode 100644 index 000000000..10109dd92 --- /dev/null +++ b/src/Support/Livewire/LivewireDataSynth.php @@ -0,0 +1,108 @@ +dataConfig = app(DataConfig::class); + + parent::__construct($context, $path); + } + + public static function match($target): bool + { + return $target instanceof BaseData && $target instanceof TransformableData; + } + + public function get(&$target, $key): BaseData + { + return $target->{$key}; + } + + public function set(&$target, $key, $value): void + { + $target->{$key} = $value; + } + + /** + * @param callable(array-key, mixed):mixed $dehydrateChild + */ + public function dehydrate( + BaseData&TransformableData&ContextableData $target, + callable $dehydrateChild + ): array { + $morph = $this->dataConfig->morphMap->getDataClassAlias($target::class) ?? $target::class; + + $payload = $target->transform( + TransformationContextFactory::create() + ->withPropertyNameMapping(false) + ->withoutWrapping() + ->withoutPropertyNameMapping() + ->withoutValueTransformation() + ); + + foreach ($payload as $key => $value) { + $payload[$key] = $dehydrateChild($key, $value); + } + + return [ + $payload, + ['morph' => $morph, 'context' => encrypt($target->getDataContext())], + ]; + } + + /** + * @param callable(array-key, mixed):mixed $hydrateChild + */ + public function hydrate( + array $value, + array $meta, + callable $hydrateChild + ): BaseData { + $morph = $meta['morph']; + $context = decrypt($meta['context']); + + $dataClass = $this->dataConfig->morphMap->getMorphedDataClass($morph) ?? $morph; + + $payload = []; + + foreach ($this->dataConfig->getDataClass($dataClass)->properties as $name => $property) { + if (array_key_exists($name, $value) === false && $property->type->lazyType) { + $payload[$name] = new LivewireLostLazy($dataClass, $name); + + continue; + } + + $payload[$name] = $hydrateChild($name, $value[$name]); + } + + /** @var CreationContextFactory $factory */ + $factory = $dataClass::factory(); + + $data = $factory + ->withPropertyNameMapping(false) + ->ignoreMagicalMethod('fromLivewire') + ->withoutValidation() + ->from($payload); + + $data->setDataContext($context); + + return $data; + } +} diff --git a/src/Support/Partials/Partial.php b/src/Support/Partials/Partial.php index 615844c0f..5d473b2a6 100644 --- a/src/Support/Partials/Partial.php +++ b/src/Support/Partials/Partial.php @@ -3,6 +3,7 @@ namespace Spatie\LaravelData\Support\Partials; use Closure; +use Laravel\SerializableClosure\SerializableClosure; use Spatie\LaravelData\Contracts\BaseData; use Spatie\LaravelData\Contracts\BaseDataCollectable; use Spatie\LaravelData\Support\Partials\Segments\AllPartialSegment; @@ -236,6 +237,30 @@ public function toArray(): array ]; } + public function toSerializedArray(): array + { + return [ + 'segments' => $this->segments, + 'permanent' => $this->permanent, + 'condition' => $this->condition + ? serialize(new SerializableClosure($this->condition)) + : null, + 'pointer' => $this->pointer, + ]; + } + + public static function fromSerializedArray(array $partial): Partial + { + return new self( + segments: $partial['segments'], + permanent: $partial['permanent'], + condition: $partial['condition'] + ? unserialize($partial['condition'])->getClosure() + : null, + pointer: $partial['pointer'], + ); + } + public function __toString(): string { return implode('.', $this->segments)." (current: {$this->pointer})"; diff --git a/src/Support/Partials/PartialsCollection.php b/src/Support/Partials/PartialsCollection.php index c3d47fb14..287ab79e0 100644 --- a/src/Support/Partials/PartialsCollection.php +++ b/src/Support/Partials/PartialsCollection.php @@ -32,6 +32,17 @@ public function toArray(): array return $output; } + public function toSerializedArray(): array + { + $output = []; + + foreach ($this as $partial) { + $output[] = $partial->toSerializedArray(); + } + + return $output; + } + public function __toString(): string { $output = ''; @@ -42,4 +53,14 @@ public function __toString(): string return $output; } + + public static function fromSerializedArray(array $collection): PartialsCollection + { + return self::create( + ...array_map( + fn (array $partial) => Partial::fromSerializedArray($partial), + $collection + ) + ); + } } diff --git a/src/Support/Transformation/DataContext.php b/src/Support/Transformation/DataContext.php index 52c0d7b08..80c7f58cb 100644 --- a/src/Support/Transformation/DataContext.php +++ b/src/Support/Transformation/DataContext.php @@ -68,4 +68,36 @@ public function getRequiredPartialsAndRemoveTemporaryOnes( return $requiredPartials; } + + public function toSerializedArray(): array + { + return [ + 'includePartials' => $this->includePartials?->toSerializedArray(), + 'excludePartials' => $this->excludePartials?->toSerializedArray(), + 'onlyPartials' => $this->onlyPartials?->toSerializedArray(), + 'exceptPartials' => $this->exceptPartials?->toSerializedArray(), + 'wrap' => $this->wrap?->toSerializedArray(), + ]; + } + + public static function fromSerializedArray(array $content): DataContext + { + return new self( + includePartials: $content['includePartials'] + ? PartialsCollection::fromSerializedArray($content['includePartials']) + : null, + excludePartials: $content['excludePartials'] + ? PartialsCollection::fromSerializedArray($content['excludePartials']) + : null, + onlyPartials: $content['onlyPartials'] + ? PartialsCollection::fromSerializedArray($content['onlyPartials']) + : null, + exceptPartials: $content['exceptPartials'] + ? PartialsCollection::fromSerializedArray($content['exceptPartials']) + : null, + wrap: $content['wrap'] + ? Wrap::fromSerializedArray($content['wrap']) + : null, + ); + } } diff --git a/src/Support/Wrapping/Wrap.php b/src/Support/Wrapping/Wrap.php index bf4ea0493..49a440e07 100644 --- a/src/Support/Wrapping/Wrap.php +++ b/src/Support/Wrapping/Wrap.php @@ -33,4 +33,20 @@ public function getKey(): null|string default => throw new TypeError('Invalid wrap') }; } + + public function toSerializedArray(): array + { + return [ + 'type' => $this->type->value, + 'key' => $this->key, + ]; + } + + public static function fromSerializedArray(array $wrap): Wrap + { + return new Wrap( + type: WrapType::from($wrap['type']), + key: $wrap['key'] ?? null, + ); + } } diff --git a/src/Support/Wrapping/WrapType.php b/src/Support/Wrapping/WrapType.php index 8b35a38f0..3aa2ea394 100644 --- a/src/Support/Wrapping/WrapType.php +++ b/src/Support/Wrapping/WrapType.php @@ -2,9 +2,9 @@ namespace Spatie\LaravelData\Support\Wrapping; -enum WrapType +enum WrapType: string { - case UseGlobal; - case Disabled; - case Defined; + case UseGlobal = 'use_global'; + case Disabled = 'disabled'; + case Defined = 'defined'; } diff --git a/tests/Fakes/ComputedData.php b/tests/Fakes/ComputedData.php new file mode 100644 index 000000000..3b9ce3fb6 --- /dev/null +++ b/tests/Fakes/ComputedData.php @@ -0,0 +1,17 @@ +name = $this->first_name . ' ' . $this->last_name; + } +} diff --git a/tests/Fakes/Livewire/ComputedDataComponent.php b/tests/Fakes/Livewire/ComputedDataComponent.php new file mode 100644 index 000000000..60d497f70 --- /dev/null +++ b/tests/Fakes/Livewire/ComputedDataComponent.php @@ -0,0 +1,33 @@ +data = new ComputedData('', ''); + } + + public function save() + { + // Trigger hydration + } + + public function render() + { + return <<<'BLADE' +
+ + + +

{{ $data->name }}

+
+ BLADE; + } +} diff --git a/tests/Fakes/Livewire/DataCollectionComponent.php b/tests/Fakes/Livewire/DataCollectionComponent.php new file mode 100644 index 000000000..82a4e3b45 --- /dev/null +++ b/tests/Fakes/Livewire/DataCollectionComponent.php @@ -0,0 +1,39 @@ +collection = SimpleData::collect([ + 'a', 'b', 'c', + ], DataCollection::class); + } + + public function save() + { + } + + public function render() + { + return <<<'BLADE' +
+ @foreach($collection as $item) +

{{ $item->string }}

+ @endforeach + +

{{ $collection[0]->string }}

+ +
+ BLADE; + } +} diff --git a/tests/Fakes/Livewire/MappedDataComponent.php b/tests/Fakes/Livewire/MappedDataComponent.php new file mode 100644 index 000000000..a9c75e688 --- /dev/null +++ b/tests/Fakes/Livewire/MappedDataComponent.php @@ -0,0 +1,32 @@ +data = new SimpleDataWithMappedProperty('Hello World'); + } + + public function save() + { + cache()->set('name', $this->data->string); + } + + public function render() + { + return <<<'BLADE' +
+ +

{{ $data->string }}

+ +
+ BLADE; + } +} diff --git a/tests/Fakes/Livewire/NestedDataComponent.php b/tests/Fakes/Livewire/NestedDataComponent.php new file mode 100644 index 000000000..7398a528d --- /dev/null +++ b/tests/Fakes/Livewire/NestedDataComponent.php @@ -0,0 +1,36 @@ +data = new NestedLazyData($nested); + + $this->data->includePermanently(...$includes); + } + + public function save() + { + cache()->set('name', $this->data->simple->string); + } + + public function render() + { + return <<<'BLADE' +
+ +

{{ $data->simple->string }}

+ +
+ BLADE; + } +} diff --git a/tests/Fakes/Livewire/SimpleDataComponent.php b/tests/Fakes/Livewire/SimpleDataComponent.php new file mode 100644 index 000000000..2651e8b9d --- /dev/null +++ b/tests/Fakes/Livewire/SimpleDataComponent.php @@ -0,0 +1,35 @@ +data = new LazyData($name); + + $this->data->includePermanently(...$includes); + } + + public function save() + { + cache()->set('name', $this->data->name); + } + + public function render() + { + return <<<'BLADE' +
+ +

{{ is_string($data->name) ? $data->name : 'lazy prop' }}

+ +
+ BLADE; + } +} diff --git a/tests/LivewireTest.php b/tests/LivewireTest.php index 52777f12e..61f62bf62 100644 --- a/tests/LivewireTest.php +++ b/tests/LivewireTest.php @@ -1,8 +1,23 @@ toLivewire())->toEqual(['name' => 'Freek']); }); + +describe('synth tests', function () { + beforeEach(function () { + app()->register(LivewireServiceProvider::class); + + Livewire::propertySynthesizer(LivewireDataSynth::class); + Livewire::propertySynthesizer(LivewireDataCollectionSynth::class); + }); + + it('can initialize a data object', function () { + livewire(SimpleDataComponent::class, ['name' => 'Hello World']) + ->assertSet('data.name', 'Hello World'); + }); + + it('can set a data object property', function () { + livewire(SimpleDataComponent::class, ['name' => 'Hello World']) + ->set('data.name', 'Hello World from Livewire') + ->assertSet('data.name', 'Hello World from Livewire') + ->assertSee('Hello World from Livewire'); + }); + + it('will not send lazy data to the front when not included', function () { + livewire(SimpleDataComponent::class, ['name' => Lazy::create(fn () => 'Hello World')]) + ->assertDontSee('Hello World'); + }); + + it('is possible to set included lazy data', function () { + livewire(SimpleDataComponent::class, ['name' => Lazy::create(fn () => 'Hello World'), 'includes' => ['name']]) + ->assertDontSee('Hello World') + ->set('data.name', 'Hello World from Livewire') + ->assertSet('data.name', 'Hello World from Livewire') + ->assertSee('Hello World from Livewire'); + }); + + it('can initialize a nested data object', function () { + livewire(NestedDataComponent::class, ['nested' => new SimpleData('Hello World')]) + ->assertSet('data.simple.string', 'Hello World'); + }); + + it('can set a nested data object property', function () { + livewire(NestedDataComponent::class, ['nested' => new SimpleData('Hello World')]) + ->set('data.simple.string', 'Hello World from Livewire') + ->assertSet('data.simple.string', 'Hello World from Livewire') + ->assertSee('Hello World from Livewire'); + }); + + it('will not map property names', function () { + livewire(MappedDataComponent::class) + ->set('data.string', 'Hello World from Livewire') + ->assertSet('data.string', 'Hello World from Livewire') + ->assertSee('Hello World from Livewire'); + }); + + it('can use computed properties', function () { + livewire(ComputedDataComponent::class) + ->set('data.first_name', 'Ruben') + ->assertSet('data.first_name', 'Ruben') + ->assertSet('data.name', ' ') // Computed properties only rerender after constructor calls + ->assertSee(' ') + ->set('data.last_name', 'Van Assche') + ->assertSet('data.last_name', 'Van Assche') + ->call('save'); + }); + + it('can use data collections', function () { + livewire(DataCollectionComponent::class) + ->assertSee('a') + ->assertSee('b') + ->assertSee('c') + ->set('collection.0.string', 'Hello World') + ->assertSet('collection.0.string', 'Hello World') + ->assertSee('Hello World') + ->call('save'); + }); +}); diff --git a/tests/Support/Transformation/DataContextTest.php b/tests/Support/Transformation/DataContextTest.php new file mode 100644 index 000000000..3828bf54f --- /dev/null +++ b/tests/Support/Transformation/DataContextTest.php @@ -0,0 +1,54 @@ + 'Check that this is true'), + Partial::create('nested.field'), + Partial::create('*'), + Partial::create('nested.field.pointed')->next(), + ), + excludePartials: null, + onlyPartials: null, + exceptPartials: null, + wrap: new Wrap(type: WrapType::Defined, key: 'key'), + ); + + $serializable = $context->toSerializedArray(); + + $serialized = serialize($serializable); + + $unserialized = unserialize($serialized); + + $deserialized = DataContext::fromSerializedArray($unserialized); + + expect($deserialized) + ->toBeInstanceOf(DataContext::class) + ->includePartials->toHaveCount(6) + ->excludePartials->toBeNull() + ->onlyPartials->toBeNull() + ->exceptPartials->toBeNull() + ->wrap->toEqual($context->wrap); + + $partials = $deserialized->includePartials->toArray(); + $expectedPartials = $context->includePartials->toArray(); + + expect($partials[0])->toEqual($expectedPartials[0]); + expect($partials[1])->toEqual($expectedPartials[1]); + expect($partials[0])->toEqual($expectedPartials[0]); + expect($partials[3])->toEqual($expectedPartials[3]); + expect($partials[4])->toEqual($expectedPartials[4]); + expect($partials[5])->toEqual($expectedPartials[5]); + + expect($partials[2]['condition']())->toEqual($expectedPartials[2]['condition']()); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 0d66d9d73..2625cf1e8 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -12,6 +12,8 @@ protected function setUp(): void { parent::setUp(); + config()->set('app.key', 'base64:'.base64_encode(random_bytes(32))); + Model::unguard(); }