1
0
Fork 0
mirror of https://github.com/archtechx/airwire.git synced 2025-12-12 10:44:03 +00:00
This commit is contained in:
Samuel Štancl 2021-05-20 20:15:55 +02:00
commit d26fa93f1e
35 changed files with 2388 additions and 0 deletions

View file

@ -0,0 +1,90 @@
<?php
use Airwire\Airwire;
use Airwire\Attributes\Wired;
use Airwire\Component;
use Illuminate\Database\Eloquent\Collection;
use function Pest\Laravel\withoutExceptionHandling;
beforeEach(fn () => Airwire::component('test-component', TestComponent::class));
test('properties are shared only if they have the Wired attribute', function () {
expect(TestComponent::test()
->state(['foo' => 'abc', 'bar' => 'xyz'])
->send()
->data
)->toBe(['bar' => 'xyz', 'results' => [], 'second' => []]); // foo is not Wired
});
test('methods are shared only if they have the Wired attribute', function () {
expect(TestComponent::test()->call('foo')->send()->call('foo'))->toBeNull();
expect(TestComponent::test()->call('bar')->send()->call('bar'))->not()->toBeNull();
});
test('exceptions thrown during method execution are returned in the metadata', function () {
expect(TestComponent::test()->call('brokenMethod')->send()->exceptions())->toHaveKey('brokenMethod')->toHaveCount(1);
expect(TestComponent::test()->call('brokenMethod')->send()->exceptions('brokenMethod'))->toMatchArray(['message' => 'foobar']);
});
test('readonly properties are not accepted by the component', function () {
expect(TestComponent::test()->state(['results' => 'foo'])->send()->data)->not()->toHaveKey('readonly');
});
test('mount can return readonly data', function () {
$response = TestComponent::test()->call('mount')->send();
expect($response->call('mount'))
->toHaveKey('results', 'foo')
->not()->toHaveKey('readonly');
});
test('properties can have custom default values', function () {
expect(TestComponent::test()->hydrate()->getState()['results'])->toBeInstanceOf(Collection::class);
expect(TestComponent::test()->hydrate()->getState()['results']->all())->toBe([]);
});
test('the frontend can send an array that should be assigned to a collection', function () {
expect(TestComponent::test()->state(['second' => ['foo' => 'bar']])->hydrate()->second->all())->toBe(['foo' => 'bar']);
});
class TestComponent extends Component
{
public $foo;
#[Wired]
public $bar;
#[Wired(readonly: true, default: [])]
public Collection $results;
#[Wired(default: [])]
public Collection $second;
public function mount()
{
return [
'readonly' => [
'results' => 'foo',
],
'bar' => 'abc',
];
}
public function foo(): int
{
return 1;
}
#[Wired]
public function bar(): int
{
return 2;
}
#[Wired]
public function brokenMethod()
{
throw new Exception('foobar');
}
}

View file

@ -0,0 +1,6 @@
<?php
// todo generated definitions
// todo : any as default
// todo do custom DTOs get TS defs as well?
// todo model relations

View file

@ -0,0 +1,203 @@
<?php
use Airwire\Airwire;
use Airwire\Attributes\Encode;
use Airwire\Component;
use Airwire\Attributes\Wired;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
beforeEach(function () {
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('description');
$table->unsignedInteger('price');
$table->string('secret')->nullable();
$table->json('variants')->default('[]');
$table->timestamps();
});
Airwire::component('typehint-component', TypehintComponent::class);
Airwire::typeTransformer(
type: MyDTO::class,
decode: fn (array $data) => new MyDTO($data['foo'], $data['abc']),
encode: fn (MyDTO $dto) => ['foo' => $dto->foo, 'abc' => $dto->abc],
);
});
// beforeEach(fn () => DB::table('products')->truncate());
afterEach(fn () => Schema::dropIfExists('products'));
test('untyped properties are set directly from the json data', function () {
foreach ([1, 'foo', ['a' => 'b']] as $value) {
expect(Airwire::test(TypehintComponent::class)
->state(['notype' => $value])
->send()->data('notype')
)->toBe($value);
}
});
test('strings and numbers are cast to the required type', function () {
expect(Airwire::test(TypehintComponent::class)
->state(['price' => '1'])
->send()->data('price')
)->toBe(1);
expect(Airwire::test(TypehintComponent::class)
->state(['name' => 123])
->send()->data('name')
)->toBe('123');
});
test('received model attributes are converted to unsaved model instances', function () {
$model = Airwire::test(TypehintComponent::class)
->state(['model' => ['name' => 'foo', 'price' => '100', 'variants' => [
['price' => 200, 'color' => 'black']
]]])
->hydrate()->model;
expect($model)->toBeInstanceOf(Product::class);
expect($model->name)->toBe('foo');
expect($model->price)->toBe(100); // Types are converted per the casts
expect($model->variants)->toBe([['price' => 200, 'color' => 'black']]); // Array casts are supported
});
test('received model attributes must be fillable', function () {
$model = Airwire::test(TypehintComponent::class)
->state(['model' => ['name' => 'foo', 'price' => '100', 'secret' => 'bar']])
->hydrate()->model;
expect($model)->toBeInstanceOf(Product::class);
expect($model->name)->toBe('foo');
expect($model->bar)->toBe(null); // Not fillable
});
test('model properties can be hidden', function () {
Product::create(['id' => 1, 'name' => 'foo', 'price' => 10, 'description' => 'bar']);
expect(Airwire::test(TypehintComponent::class)
->call('first')
->send()->call('first')
)->toBeArray()->toHaveKey('created_at')->not()->toHaveKey('updated_at');
});
test('received model ids are converted to model instances', function () {
Product::create(['id' => 1, 'name' => 'foo', 'price' => 10, 'description' => 'bar']);
$model = Airwire::test(TypehintComponent::class)
->state(['model' => 1])
->hydrate()->model;
expect($model)->toBeInstanceOf(Product::class);
expect($model->id)->toBe(1);
expect($model->exists())->toBe(true);
});
test('sent models are converted to arrays', function () {
Product::create(['id' => 1, 'name' => 'foo', 'price' => 10, 'description' => 'bar']);
expect(Airwire::test(TypehintComponent::class)
->call('first')
->send()->call('first')
)->toBeArray()->toHaveKey('id', 1);
});
test('custom DTOs can be used', function () {
// Sending
expect(Airwire::test(TypehintComponent::class)
->state(['dto' => [
'foo' => 'bar',
'abc' => 123,
]])
->hydrate()
->dto
)->toBeInstanceOf(MyDTO::class)->toHaveKey('foo', 'bar')->toHaveKey('abc', 123);
// Receiving
expect(Airwire::test(TypehintComponent::class)
->state(['dto' => [
'foo' => 'bar',
'abc' => 123,
]])
->send()
->data('dto')
)->toBe(['foo' => 'bar', 'abc' => 123]);
});
test('model can be passed to a method', function () {
expect(TypehintComponent::test()
->call('save', ['name' => 'foo', 'price' => 10, 'description' => 'bar'])
->send()
->call('save')
)->toBe('1');
expect(Product::count())->toBe(1);
});
test('models can be encoded back to the id', function () {
Product::create(['id' => 1, 'name' => 'foo', 'price' => 10, 'description' => 'bar']);
expect(TypehintComponent::test()
->state(['model2' => 1])
->send()->data('model2')
)->toBe(1);
});
class TypehintComponent extends Component
{
#[Wired]
public $notype;
#[Wired]
public string $name;
#[Wired]
public int $price;
#[Wired]
public Product $model;
#[Wired] #[Encode(method: 'getKey')] // todo add the same feature for Decode
public Product $model2;
#[Wired]
public MyDTO $dto;
#[Wired]
public function first(): Product
{
return Product::first();
}
#[Wired]
public function save(Product $model): string
{
$model->save();
return $model->id;
}
}
class Product extends Model
{
public $fillable = ['id', 'name', 'price', 'description', 'variants'];
public $hidden = ['updated_at'];
public $casts = [
'price' => 'int',
'variants' => 'array',
];
}
class MyDTO
{
public function __construct(
public string $foo,
public int $abc,
) {}
}

View file

@ -0,0 +1,220 @@
<?php
use Airwire\Airwire;
use Airwire\Attributes\Wired;
use Airwire\Component;
beforeAll(function () {
Airwire::component('autovalidated-component', AutovalidatedComponent::class);
Airwire::component('manually-validated-component', ManuallyValidatedComponent::class);
Airwire::component('multi-input-component', MultiInputComponent::class);
Airwire::component('implicitly-validated-component', ImplicitlyValidatedComponent::class);
});
test('validation is executed on the new state (old state + changes)', function () {
// ❌ Old state was invalid, too short name
expect(Airwire::test('autovalidated-component')
->state(['name' => 'sam'])
->send()->errors()
)->not()->toBeEmpty();
// ✅ Valid state after changes are applied
expect(Airwire::test('autovalidated-component')
->state(['name' => 'sam'])
->changes(['name' => 'sam 123456789'])
->send()->errors()
)->toBeEmpty();
// ❌ Invalid state after changes arre applied
expect(Airwire::test('autovalidated-component')
->state(['name' => 'sam 123456789'])
->changes(['email' => 'sam123456789@toolong.com'])
->send()->errors()
)->not()->toBeEmpty();
});
test('properties CANNOT be changed when validation fails and strict validation is ON', function () {
expect(Airwire::test('autovalidated-component')
->state(['name' => 'original'])
->changes(['name' => 'failing'])
->send()->data('name')
)->toBe('original');
});
// Strict validation prevents ANY EXECUTION AT ALL when the received state is invalid
test('properties CANNOT be changed when validation fails for any other properties and strict validation is ON', function () {
expect(Airwire::test('autovalidated-component')
->state(['name' => 'original', 'email' => 'original@email'])
->changes(['name' => 'failing', 'email' => 'new@email'])
->send()->data
)->toBe([
'name' => 'original',
'email' => 'original@email',
]);
});
test('methods CANNOT be called when validation fails and strict validation is ON', function () {
expect(Airwire::test('autovalidated-component')
->state(['name' => 'sam'])
->call('foo')
->send()->metadata->calls
)->not()->toHaveKey('foo');
});
test('properties CAN be changed when validation fails and strict validation is OFF', function () {
expect(Airwire::test('manually-validated-component')
->state(['name' => 'sam']) // Failing validation
->changes(['name' => 'failing'])
->send()->data('name')
)->toBe('failing');
});
test('only changes are reversed, old state will be returned even if it is invalid', function () {
$response = Airwire::test('manually-validated-component')
->state(['name' => 'sam']) // ❌ Failing validation
->call('foo')
->send();
expect($response->data('name'))->toBe('sam');
});
test('when methods call validate() and it fails, execution is stopped', function () {
$response = Airwire::test('manually-validated-component')
->state(['name' => 'sam']) // ❌ Failing validation
->call('foo') // No validation
->send();
expect($response->data('name'))->toBe('sam');
expect($response->call('foo'))->toBe('bar');
expect($response->call('abc'))->toBe(null);
$response = Airwire::test('manually-validated-component')
->state(['name' => 'sam 123456789', 'email' => 'foo']) // ✅ Passing validation
->call('foo')
->call('abc') // Manual validation
->send();
expect($response->call('foo'))->toBe('bar');
expect($response->call('abc'))->toBe('xyz');
});
test('validate can be used to prevent updating', function () {
expect(MultiInputComponent::test()
->state(['name' => 'original', 'email' => 'valid@mail'])
->changes(['name' => 'invalid'])
->send()->data('name')
)->toBe('original');
});
test('validated can be used to validate the specified properties and get their values', function () {
expect(MultiInputComponent::test()
->state(['name' => 'invalid', 'email' => 'valid@mail'])
->call('method')
->send()->call('method')
)->toBe(null);
expect(MultiInputComponent::test()
->state(['name' => 'very valid', 'email' => 'valid@mail'])
->call('method')
->send()->call('method')
)->toBe(['name' => 'very valid', 'email' => 'valid@mail']);
});
test('uncaught validation exceptions in hydrate terminate execution', function () {
expect(
ImplicitlyValidatedComponent::test()
->state(['foo' => 'abc'])
->send()->errors()
)->toHaveKey('foo');
});
class AutovalidatedComponent extends Component
{
#[Wired]
public string $name;
#[Wired]
public string $email;
public $rules = [
'name' => ['required', 'min:10'],
'email' => ['nullable', 'max:10'],
];
#[Wired]
public function foo()
{
return 'bar';
}
}
class ManuallyValidatedComponent extends Component
{
public bool $strictValidation = false;
#[Wired]
public string $name;
#[Wired]
public string $email;
public $rules = [
'name' => ['required', 'min:10'],
'email' => ['nullable', 'max:10'],
];
#[Wired]
public function foo()
{
return 'bar';
}
#[Wired]
public function abc()
{
$this->validate();
return 'xyz';
}
}
class MultiInputComponent extends Component
{
public bool $strictValidation = false;
#[Wired]
public string $name;
#[Wired]
public string $email;
public $rules = [
'name' => ['required', 'min:10'],
'email' => ['nullable', 'max:20'],
];
public function updating(string $property, mixed $new, mixed $old): bool
{
return $this->validate($property);
}
#[Wired]
public function method()
{
return $this->validated();
}
}
class ImplicitlyValidatedComponent extends Component
{
public bool $strictValidation = false;
#[Wired()]
public string $foo;
public array $rules = [
'foo' => ['required', 'min:10'],
];
// validated in dehydrate()
}