1
0
Fork 0
mirror of https://github.com/archtechx/airwire.git synced 2025-12-12 02:34:04 +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

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
/vendor/
composer.lock
node_modules
package-lock.json
.phpunit.result.cache

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 ARCHTECH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

0
README.md Normal file
View file

32
composer.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "archtechx/airwire",
"description": "A lightweight full-stack component layer that doesn't dictate your front-end framework",
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Samuel Štancl",
"email": "samuel@archte.ch"
}
],
"autoload": {
"psr-4": {
"Airwire\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Airwire\\Tests\\": "tests/"
}
},
"require-dev": {
"pestphp/pest": "^1.2",
"illuminate/testing": "^8.42",
"pestphp/pest-plugin-laravel": "^1.0.0"
},
"require": {
"illuminate/support": "^8.42",
"illuminate/console": "^8.42",
"orchestra/testbench": "^6.17"
}
}

5
package.json Normal file
View file

@ -0,0 +1,5 @@
{
"devDependencies": {
"@types/node": "^15.3.1"
}
}

25
phpunit.xml Normal file
View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="Airwire">
<directory suffix="Test.php">./tests/Airwire</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./app</directory>
</include>
</coverage>
<php>
<server name="APP_KEY" value="AckfSECXIvnK5r28GVIWUAxmbBSjTsmF" />
<server name="APP_ENV" value="testing"/>
<server name="BCRYPT_ROUNDS" value="4"/>
<server name="CACHE_DRIVER" value="array"/>
<server name="DB_CONNECTION" value="sqlite"/>
<server name="DB_DATABASE" value=":memory:"/>
<server name="MAIL_MAILER" value="array"/>
<server name="QUEUE_CONNECTION" value="sync"/>
<server name="SESSION_DRIVER" value="array"/>
<server name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>

View file

@ -0,0 +1,23 @@
const chokidar = require('chokidar');
const exec = require('child_process').exec;
class AirwireWatcher {
constructor(files = 'app/**/*.php') {
this.files = files;
}
apply(compiler) {
compiler.hooks.afterEnvironment.tap('AirwireWatcher', () => {
chokidar
.watch(this.files, { usePolling: false, persistent: true })
.on('change', this.fire);
});
}
fire() {
exec('php artisan airwire:generate');
console.log('Rebuilding Airwire definitions');
}
}
module.exports = AirwireWatcher;

13
resources/js/_types.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
import './airwired'
declare module 'airwire' {
export interface TypeMap {
String: any;
}
}
declare global {
interface Window {
Airwire: Airwire
}
}

60
resources/js/airwired.ts Normal file
View file

@ -0,0 +1,60 @@
declare module 'airwire' {
export interface AirwireException {
message: string,
exception: string,
file: string,
line: number,
trace: Array<{
class: string,
file: string,
function: string,
line: number,
type: string,
}>,
}
export interface AirwirePromise<T> extends Promise<T> {
then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: AirwireException) => TResult2 | PromiseLike<TResult2>) | undefined | null): AirwirePromise<TResult1 | TResult2>;
catch<TResult = never>(onrejected?: ((reason: AirwireException) => TResult | PromiseLike<TResult>) | undefined | null): AirwirePromise<T | TResult>;
}
export type NonFunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? never : K }[keyof T];
export type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;
export type FunctionProperties<T> = Omit<T, NonFunctionPropertyNames<T>>;
export type StringKeys<T> = Pick<T, Extract<keyof T, string>>;
export type WiredMethods<T> = StringKeys<Partial<FunctionProperties<Omit<T, 'mount' | 'watch'>>>>;
export type WiredProperties<T> = StringKeys<Partial<NonFunctionProperties<Omit<T, 'errors' | '\$component'>>>>;
export type Magic<T> = T & { [key: string]: any };
export type ComponentResponse<Component> = {
data: WiredProperties<Component>;
metadata: {
calls?: {
[key in keyof WiredMethods<Component>]: any;
};
exceptions?: {
[key in keyof WiredMethods<Component>]: AirwireException;
};
errors?: {
[key in keyof WiredProperties<Component>]: string[];
};
readonly: Array<keyof WiredProperties<Component>>;
[key: string]: any;
}
}
type Watchers<T> = {
responses: Array<(response: ComponentResponse<T>) => void>,
errors: Array<(error: AirwireException) => void>,
};
export type TypeNames = keyof TypeMap
export type TypeName<T> = T extends TypeNames
? TypeMap[T]
: never
}

311
resources/js/index.ts Normal file
View file

@ -0,0 +1,311 @@
import { TypeMap, Watchers, WiredProperties, WiredMethods, AirwireException, AirwirePromise, ComponentResponse, Magic, TypeName, TypeNames } from 'airwire'
export class Component<AirwireComponent = TypeMap[keyof TypeMap]>
{
public proxy: any;
public loading: boolean = false;
public errors: Record<string, string[]> = {};
public _proxyTarget: Component<AirwireComponent>;
public watchers: Watchers<AirwireComponent> = { responses: [], errors: [] };
public pausedRequests: boolean = false;
public pendingChanges: Partial<{ [key in keyof WiredProperties<AirwireComponent>]: any }> = {};
public pendingCalls: Partial<{ [key in keyof WiredMethods<AirwireComponent>]: any }> = {};
public readonly: Partial<{ [key in keyof WiredProperties<AirwireComponent>]: any }> = {};
public reactive: CallableFunction;
constructor(
public alias: keyof TypeMap,
public state: any,
reactive: CallableFunction|null = null,
) {
this.reactive = reactive ?? window.Airwire.reactive;
this.readonly = state.readonly ?? {};
delete this.state.readonly;
let component: Component<AirwireComponent> = this._proxyTarget = this.reactive(this);
window.Airwire.components[alias] ??= [];
window.Airwire.components[alias]?.push(component as any);
// We never use `this` in this class, because we always want to refer to the singleton reactive proxy
component.watch(response => {
let mount = (response.metadata.calls as any)?.mount;
if (mount) {
component.replaceState(mount, response.metadata.readonly);
}
});
this.proxy = new Proxy(component.state, {
get(target, property: string | symbol) {
if (property === 'deferred') {
return new Proxy(component.state, {
get(target, property) {
return component.proxy[property]
},
set(target, property, value) {
component.pendingChanges[property as keyof WiredProperties<AirwireComponent>] = value;
return true
}
});
}
if (property === 'readonly') {
return component.readonly;
}
if (property === '$component') {
return component;
}
// Methods are returned using wrapper methods bypass the Proxy
let methods = ['watch', 'defer', 'refresh', 'remount'];
if (typeof property === 'string' && methods.includes(property)) {
return function (...args: any[]) {
return component[property as keyof typeof component](...args);
};
}
// Whitelisted Component properties
let properties = ['errors', 'loading'];
if (typeof property === 'string' && properties.includes(property)) {
return component[property as keyof typeof component];
}
if (typeof property === 'string' && Object.keys(component.state).includes(property)) {
return component.state[property];
}
if (typeof property === 'string' && Object.keys(component.readonly).includes(property)) {
return component.readonly[property as keyof WiredProperties<AirwireComponent>];
}
if (typeof property === 'string' && !property.startsWith('__v') && property !== 'toJSON') {
return function (...args: any[]) {
return component.call.apply(component, [
property as keyof WiredMethods<AirwireComponent>,
...args
]);
}
}
},
set(target, property: string, value) {
component.update(property as keyof WiredProperties<AirwireComponent>, value);
return true
}
})
}
public update(property: keyof WiredProperties<AirwireComponent>, value: any): Promise<ComponentResponse<AirwireComponent>> | null {
this.state[property] = value;
if (this.pausedRequests) {
this.pendingChanges[property] = value;
return null;
}
return this.request(property, {
changes: { [property]: value }
}, (json: ComponentResponse<AirwireComponent>) => {
if (json?.metadata?.exceptions) {
return Promise.reject(json.metadata.exceptions);
}
return json
})
}
public call(method: keyof WiredMethods<AirwireComponent>, ...args: any[]): AirwirePromise<any> | null {
if (this.pausedRequests) {
this.pendingCalls[method] = args;
return null;
}
return this.request(method, {
calls: { [method]: args }
}, (json: ComponentResponse<AirwireComponent>) => {
if (json?.metadata?.exceptions) {
return Promise.reject(json.metadata.exceptions[method] ?? json.metadata.exceptions);
}
return json;
}).then((json: ComponentResponse<AirwireComponent>) => json?.metadata?.calls?.[method] ?? null);
}
public request(target: string, data: {
calls?: { [key in string]: any[] },
changes?: { [key in string]: any },
}, callback: (json: ComponentResponse<AirwireComponent>) => any = (json: ComponentResponse<AirwireComponent>) => json): AirwirePromise<ComponentResponse<AirwireComponent>> {
this.loading = true;
let pendingChanges = this.pendingChanges;
this.pendingChanges = {};
let pendingCalls = this.pendingCalls;
this.pendingCalls = {};
let path = window.Airwire.route;
return fetch(`${path}/${this.alias}/${target}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
state: this.state,
calls: { ...pendingCalls, ...data?.calls ?? {} },
changes: { ...pendingChanges, ...data?.changes ?? {} },
})
})
.then(response => response.json())
.then((json: ComponentResponse<AirwireComponent>) => {
this.loading = false
window.Airwire.watchers.responses.forEach((watcher: any) => watcher(json as any))
this.watchers.responses.forEach(watcher => watcher(json))
return callback(json)
})
.catch((reason: AirwireException) => {
this.loading = false
window.Airwire.watchers.errors.forEach((watcher: any) => watcher(reason))
this.watchers.errors.forEach(watcher => watcher(reason))
return reason
})
.then((json: ComponentResponse<AirwireComponent>) => { // todo this then() shouldn't execute if previous catch() executes
if (json?.metadata?.errors) {
this.errors = json.metadata.errors
}
this.replaceState(json.data, json?.metadata?.readonly)
return json
})
}
public replaceState(state: any, readonly: any[]) {
Object.entries(state).forEach(([key, value]) => {
if (readonly && readonly.includes && readonly.includes(key)) {
this.readonly[key as keyof WiredProperties<AirwireComponent>] = value;
// Clean up state if the property wasn't readonly from the beginning
if (this.state[key] !== undefined) {
delete this.state[key];
}
} else {
this.state[key] = value;
}
})
}
public watch(responses: (response: ComponentResponse<AirwireComponent>) => void, errors?: (error: AirwireException) => void): void {
this.watchers.responses.push(responses);
if (errors) {
this.watchers.errors.push(errors);
}
}
public defer<T>(callback: () => T): T | null {
this.pausedRequests = true;
let result = null;
try {
result = callback();
} catch (e) { }
this.pausedRequests = false;
return result;
}
public refresh() {
return this.request('refresh', {});
}
public remount(...args: any[]) {
return this.request('mount', {
calls: {
mount: args,
}
});
}
}
export class Airwire {
public route: string = '/airwire';
public watchers: Watchers<TypeMap[keyof TypeMap]> = { responses: [], errors: [] };
public components: Partial<{ [T in keyof TypeMap]: Array<Component<TypeMap[T]>> }> = {};
public constructor(
public componentDefaults: any = {},
public reactive: CallableFunction = (component: Component) => component,
) { }
public watch(responses: (response: ComponentResponse<TypeMap[keyof TypeMap]>) => void, errors?: (error: AirwireException) => void): Airwire {
this.watchers.responses.push(responses);
if (errors) {
this.watchers.errors.push(errors);
}
return this;
}
public remount(aliases: keyof TypeMap | Array<keyof TypeMap> | null = null): void {
this.refresh(aliases, true)
}
public refresh(aliases: keyof TypeMap | Array<keyof TypeMap> | null = null, remount: boolean = false): void {
if (typeof aliases === 'string') {
aliases = [aliases];
}
if (! aliases) {
aliases = Object.keys(this.components) as Array<keyof TypeMap>;
}
for (const alias of aliases) {
this.components[alias]?.forEach((component: Component<any>) => {
if (remount) {
component.remount();
} else {
component.refresh();
}
});
}
}
public component<K extends TypeNames>(alias: K, state: WiredProperties<TypeName<K>>, reactive: CallableFunction | null = null): Magic<TypeName<K>> {
const component = new Component<TypeName<K>>(alias, {
...this.componentDefaults[alias] ?? {},
...state
}, reactive);
return component.proxy as TypeName<K>;
}
public plugin(name: string) {
return require('./plugins/' + name).default;
}
}
export default Airwire;

View file

@ -0,0 +1,19 @@
let state = window as any;
export default () => {
const deferrer = state.deferLoadingAlpine || function (callback: CallableFunction) { callback() }
state.deferLoadingAlpine = function (callback: CallableFunction) {
state.Alpine.addMagicProperty('$airwire', (el: any) => {
return function (...args: any) {
if (args) {
return window.Airwire.component(args[0], args[1], el.__x.$data.$reactive)
}
return window.Airwire;
}
})
deferrer(callback)
}
}

View file

@ -0,0 +1,6 @@
export default (reactive: any) => ({
install(app: any) {
window.Airwire.reactive = reactive;
app.config.globalProperties.$airwire = window.Airwire
}
})

6
routes/airwire.php Normal file
View file

@ -0,0 +1,6 @@
<?php
use Airwire\Http\AirwireController;
use Illuminate\Support\Facades\Route;
Route::post('/airwire/{component}/{target?}', AirwireController::class)->name('airwire.component');

191
src/Airwire.php Normal file
View file

@ -0,0 +1,191 @@
<?php
namespace Airwire;
use Airwire\Attributes\Encode;
use Airwire\Testing\RequestBuilder;
use Exception;
use ReflectionNamedType;
use ReflectionParameter;
use ReflectionProperty;
use ReflectionType;
use ReflectionUnionType;
class Airwire
{
public static array $components = [];
public static array $typeTransformers = [];
public static function component(string $alias, string $class): void
{
static::$components[$alias] = $class;
}
public static function hasComponent(string $alias): bool
{
return isset(static::$components[$alias]);
}
public static function typeTransformer(string $type, callable $decode, callable $encode): void
{
static::$typeTransformers[$type] = compact('decode', 'encode');
}
public static function getDefaultDecoder(): callable
{
return fn (array $data, string $class) => new $class($data);
}
public static function getDefaultEncoder(): callable
{
return fn ($object) => json_decode(json_encode($object), true);
}
public static function decode(ReflectionProperty|ReflectionParameter|array $property, mixed $value): mixed
{
if ($value === null) {
return null;
}
if (is_array($property)) {
$property = new ReflectionProperty(...$property);
}
if ($property->getType() instanceof ReflectionUnionType) {
$types = $property->getType()->getTypes();
} else {
$types = [$property->getType()];
}
foreach ($types as $type) {
// No type = no transformer
if ($type === null) {
continue;
}
if ($type->isBuiltin() && gettype($value) === $type->getName()) {
continue;
}
$class = $type->getName();
$decoder = static::findDecoder($class);
if ($decoder) {
return $decoder($value, $class);
}
}
// No class was found
if (! isset($class)) {
return $value;
}
return static::getDefaultDecoder()($value, $class);
}
public static function encode(ReflectionProperty|ReflectionParameter|ReflectionNamedType|ReflectionUnionType|array $property, mixed $value): mixed
{
if ($value === null) {
return null;
}
if (is_array($property)) {
$property = new ReflectionProperty(...$property);
}
if (($property instanceof ReflectionProperty || $property instanceof ReflectionParameter) && count($encodeAttributes = $property->getAttributes(Encode::class))) {
return $encodeAttributes[0]->newInstance()->encode($value);
}
if ($property instanceof ReflectionType) {
$type = $property;
} else {
$type = $property->getType();
}
if ($type instanceof ReflectionUnionType) {
$types = $type->getTypes();
} else {
$types = [$type];
}
foreach ($types as $type) {
// No type = no transformer
if ($type === null) {
continue;
}
if ($type->isBuiltin() && gettype($value) === $type->getName()) {
continue;
}
$class = $type->getName();
$encoder = static::findEncoder($class);
if ($encoder) {
return $encoder($value, $class);
}
}
return static::getDefaultEncoder()($value);
}
public static function findDecoder(string $class): callable|null
{
if (class_exists($class)) {
return static::getTransformer($class)['decode'] ?? null;
}
return match ($class) {
'int' => fn ($val) => (int) $val,
'string' => fn ($val) => (string) $val,
'float' => fn ($val) => (float) $val,
'bool' => fn ($val) => (bool) $val,
default => null,
};
}
public static function findEncoder(string $class): callable|null
{
if (class_exists($class)) {
return static::getTransformer($class)['encode'] ?? null;
}
return null;
}
public static function getTransformer(string $class): array|null
{
$transformer = null;
while (! $transformer) {
if (isset(static::$typeTransformers[$class])) {
$transformer = static::$typeTransformers[$class];
}
if (! $class = get_parent_class($class)) {
break;
}
}
return $transformer;
}
public static function test(string $component): RequestBuilder
{
if (isset(static::$components[$component])) {
return new RequestBuilder($component);
} else if (in_array($component, static::$components)) {
return new RequestBuilder(array_search($component, static::$components));
}
throw new Exception("Component {$component} not found.");
}
public static function routes()
{
require __DIR__ . '/../routes/airwire.php';
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace Airwire;
use Airwire\Commands\GenerateDefinitions;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\LazyCollection;
use Illuminate\Support\ServiceProvider;
class AirwireServiceProvider extends ServiceProvider
{
public function boot()
{
$this->commands([GenerateDefinitions::class]);
$this->loadDefaultTransformers();
$this->loadRoutesFrom(__DIR__ . '/../routes/airwire.php');
}
public function loadDefaultTransformers(): void
{
Airwire::typeTransformer(
Model::class,
decode: function (mixed $data, string $model) {
$keyName = $model::make()->getKeyName();
if (is_array($data)) {
if (isset($data[$keyName])) {
if ($instance = $model::find($data[$keyName])) {
return $instance;
}
}
return new $model($data);
} else {
return $model::find($data);
}
},
encode: fn (Model $model) => $model->toArray()
);
Airwire::typeTransformer(
Collection::class,
decode: fn (array $data, string $class) => new $class($data),
encode: fn (Collection $collection) => $collection->toArray(),
);
Airwire::typeTransformer(
LazyCollection::class,
decode: fn (array $data, string $class) => new $class($data),
encode: fn (LazyCollection $collection) => $collection->toArray(),
);
}
}

32
src/Attributes/Encode.php Normal file
View file

@ -0,0 +1,32 @@
<?php
namespace Airwire\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY)]
class Encode
{
public function __construct(
public string|null $property = null,
public string|null $method = null,
public string|null $function = null,
) {}
public function encode(mixed $value): mixed
{
if ($this->property && isset($value->${$this->property})) {
return $value->{$this->property};
}
if ($this->method && method_exists($value, $this->method)) {
return $value->{$this->method}();
}
if ($this->function && function_exists($this->function)) {
return ($this->function)($value);
}
return null;
}
}

15
src/Attributes/Wired.php Normal file
View file

@ -0,0 +1,15 @@
<?php
namespace Airwire\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)]
class Wired
{
public function __construct(
public mixed $default = null,
public bool $readonly = false,
public string|null $type = null,
) {}
}

View file

@ -0,0 +1,72 @@
<?php
namespace Airwire\Commands;
use Airwire\Airwire;
use Airwire\TypehintConverter;
use Illuminate\Console\Command;
class GenerateDefinitions extends Command
{
public static string $dir;
protected $signature = 'airwire:generate';
protected $description = 'Command description';
public function handle()
{
$defaults = [];
$components = '';
$typemap = "type TypeMap = {\n";
$converter = (new TypehintConverter);
foreach (Airwire::$components as $alias => $class) {
$defaults[$alias] = (new $class([]))->getState();
$components .= $converter->convertComponent(new $class([])) . "\n\n";
$className = $converter->getClassName($class);
$typemap .= " '{$alias}': {$className}\n";
}
$namedTypes = '';
foreach ($converter->namedTypes as $alias => $type) {
$namedTypes .= "type {$alias} = {$type};\n\n";
}
$typemap .= "}";
$defaults = json_encode($defaults);
file_put_contents(resource_path('js/airwire.ts'), <<<JS
// This file is generated by Airwire
export const componentDefaults = {$defaults}
import Airwire from './../../vendor/archtechx/airwire/resources/js';
export default window.Airwire = new Airwire(componentDefaults)
declare global {
interface Window {
Airwire: Airwire
}
}
JS);
file_put_contents(resource_path('js/airwired.d.ts'), <<<JS
declare global {
{$namedTypes}
}
import './../../vendor/archtechx/airwire/resources/js/airwired'
declare module 'airwire' {
export {$typemap}
{$components}
}
JS);
}
}

65
src/Component.php Normal file
View file

@ -0,0 +1,65 @@
<?php
namespace Airwire;
use Airwire\Testing\RequestBuilder;
abstract class Component
{
use Concerns\ManagesState,
Concerns\ManagesActions,
Concerns\ManagesLifecycle,
Concerns\ManagesValidation;
public array $requestState;
public string $requestTarget;
public array $changes;
public array $calls;
public function __construct(array $state)
{
foreach ($this->getSharedProperties() as $property) {
if (isset($state[$property]) && ! $this->isReadonly($property)) {
$this->$property = Airwire::decode([$this, $property], $state[$property]);
} else {
unset($state[$property]);
}
}
$this->requestState = $state;
}
public function handle(array $changes, array $calls, string $target = null): static
{
$this->changes = $changes;
$this->calls = $calls;
$this->requestTarget = $target;
if (isset($calls['mount']) && $target === 'mount' && method_exists($this, 'mount')) {
$this->makeCalls(['mount' => $calls['mount']]);
$this->hasBeenReset = true; // Ignore validation, we're in original state - no request with a user interaction was made
} else {
if ($this->hydrateComponent()) {
$this->makeChanges($changes);
$this->makeCalls($calls);
}
}
$this->dehydrateComponent();
return $this;
}
public function response(): array
{
return [
'data' => $this->getEncodedState(),
'metadata' => $this->metadata,
];
}
public static function test(): RequestBuilder
{
return Airwire::test(static::class);
}
}

View file

@ -0,0 +1,122 @@
<?php
namespace Airwire\Concerns;
use Airwire\Airwire;
use ReflectionMethod;
use ReflectionObject;
use Illuminate\Foundation\Exceptions\Handler;
use Illuminate\Validation\ValidationException;
use Throwable;
trait ManagesActions
{
public array $readonly = [];
public function makeChanges(array $changes): void
{
foreach ($this->getSharedProperties() as $property) {
if (isset($changes[$property]) && ! $this->isReadonly($property)) {
if (! $this->makeChange($property, $changes[$property])) {
unset($changes[$property]);
}
} else {
unset($changes[$property]);
}
}
if ($changes) {
try {
$this->changed($changes);
} catch (ValidationException) {}
}
}
protected function makeChange(string $property, mixed $new): bool
{
$old = $this->$property ?? null;
try {
if ($this->updating($property, $new, $old) === false) {
return false;
}
if (method_exists($this, $method = ('updating' . ucfirst($property)))) {
if ($this->$method($new, $old) === false) {
return false;
}
}
} catch (ValidationException $e) {
return false;
}
$this->$property = $new;
$this->updated($property, $new);
if (method_exists($this, $method = ('updated' . ucfirst($property)))) {
$this->$method($new, $old);
}
return true;
}
public function makeCalls(array $calls): void
{
$this->metadata['calls'] ??= [];
foreach ($this->getSharedMethods() as $method) {
if (isset($calls[$method])) {
try {
$result = $this->callWiredMethod($method, $calls[$method]);
if ($method === 'mount') {
if (isset($result['readonly'])) {
$readonly = $result['readonly'];
unset($result['readonly']);
$result = array_merge($readonly, $result);
$this->readonly = array_unique(array_merge(
$this->readonly, array_keys($readonly)
));
}
}
$this->metadata['calls'][$method] = $result;
} catch (Throwable $e) {
if (! app()->isProduction() && ! $e instanceof ValidationException) {
$reflection = (new ReflectionObject($handler = (new Handler(app()))))->getMethod('convertExceptionToArray');
$reflection->setAccessible(true);
$this->metadata['exceptions'] ??= [];
$this->metadata['exceptions'][$method] = $reflection->invoke($handler, $e);
}
}
}
}
}
protected function callWiredMethod(string $method, array $arguments): mixed
{
$reflectionMethod = new ReflectionMethod($this, $method);
$parameters = $reflectionMethod->getParameters();
foreach ($arguments as $index => &$value) {
if (! isset($parameters[$index])) {
break;
}
$parameter = $parameters[$index];
$value = Airwire::decode($parameter, $value);
}
$result = $this->$method(...$arguments);
if ($returnType = $reflectionMethod->getReturnType()) {
return Airwire::encode($returnType, $result);
} else {
return $result;
}
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace Airwire\Concerns;
use Illuminate\Validation\ValidationException;
trait ManagesLifecycle
{
public function hydrateComponent(): bool
{
if ($this->strictValidation && $this->validate(throw: false) === false) {
return false;
}
if (method_exists($this, 'hydrate')) {
$hydrate = app()->call([$this, 'hydrate'], $this->requestState);
if (is_bool($hydrate)) {
return $hydrate;
}
}
return true;
}
public function dehydrateComponent(): void
{
try {
$this->validate();
if (method_exists($this, 'dehydrate')) {
app()->call([$this, 'dehydrate'], $this->requestState);
}
} catch (ValidationException) {}
if (isset($this->errors) && ! $this->hasBeenReset) {
$this->metadata['errors'] = $this->errors->toArray();
} else {
$this->metadata['errors'] = [];
}
$this->metadata['readonly'] = array_unique(array_merge($this->readonly, $this->getReadonlyProperties()));
}
public function updating(string $property, mixed $new, mixed $old): bool
{
return true;
}
public function updated(string $property, mixed $value): void
{
}
public function changed(array $changes): void
{
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace Airwire\Concerns;
use Airwire\Airwire;
use ReflectionMethod;
use ReflectionObject;
use ReflectionProperty;
use Airwire\Attributes\Wired;
trait ManagesState
{
public bool $hasBeenReset = false;
public function getSharedProperties(): array
{
return collect((new ReflectionObject($this))->getProperties())
->filter(
fn (ReflectionProperty $property) => collect($property->getAttributes(Wired::class))->isNotEmpty()
)
->map(fn (ReflectionProperty $property) => $property->getName())
->toArray();
}
public function getSharedMethods(): array
{
return collect((new ReflectionObject($this))->getMethods())
->filter(
fn (ReflectionMethod $method) => collect($method->getAttributes(Wired::class))->isNotEmpty()
)
->map(fn (ReflectionMethod $method) => $method->getName())
->merge(method_exists($this, 'mount') ? ['mount'] : [])
->toArray();
}
public function getState(): array
{
return collect($this->getSharedProperties())
->combine($this->getSharedProperties())
->map(function (string $property) {
if (isset($this->$property)) {
return $this->$property;
}
$default = optional((new ReflectionProperty($this, $property))->getAttributes(Wired::class))[0]->newInstance()->default;
if ($default !== null) {
return Airwire::decode([$this, $property], $default);
}
return null;
})
->all();
}
public function getEncodedState(): array
{
return collect($this->getState())
->map(fn ($value, $key) => Airwire::encode([$this, $key], $value))
->toArray();
}
public function getReadonlyProperties(): array
{
return collect($this->getSharedProperties())
->filter(fn (string $property) => $this->isReadonly($property))
->values()
->toArray();
}
public function isReadonly(string $property): bool
{
$attributes = (new ReflectionProperty($this, $property))->getAttributes(Wired::class);
return count($attributes) === 1 && $attributes[0]->newInstance()->readonly === true;
}
public function reset(array $properties = null): void
{
$properties ??= $this->getSharedProperties();
foreach ($properties as $property) {
unset($this->$property);
}
$this->hasBeenReset = true;
}
public function meta(string|array $key, mixed $value): void
{
if (is_array($key)) {
$this->metadata = array_merge($this->metadata, $key);
} else {
$this->metadata[$key] = $value;
}
}
}

View file

@ -0,0 +1,84 @@
<?php
namespace Airwire\Concerns;
use Illuminate\Contracts\Validation\Validator as AbstractValidator;
use Illuminate\Support\Arr;
use Illuminate\Support\MessageBag;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
trait ManagesValidation
{
public bool $strictValidation = true;
public MessageBag $errors;
/** @throws ValidationException */
public function validate(string|array $properties = null, bool $throw = true): bool
{
$validator = $this->validator($properties);
if ($validator->fails()) {
if (isset($this->errors)) {
foreach ($validator->errors()->toArray() as $property => $errors) {
foreach ($errors as $error) {
if (! in_array($error, $this->errors->get($property))) {
$this->errors->add($property, $error);
}
}
}
} else {
$this->errors = $validator->errors();
}
if ($throw) {
$validator->validate();
} else {
return false;
}
}
return true;
}
/** @throws ValidationException */
public function validated(string|array $properties = null): array
{
return $this->validator($properties)->validated();
}
public function validator(string|array $properties = null): AbstractValidator
{
$state = array_merge($this->getState(), $this->changes);
$rules = $this->rules();
$messages = $this->messages();
$attributes = $this->attributes();
$properties = $properties
? Arr::wrap($properties)
: $this->getSharedProperties();
$state = collect($state)->only($properties)->toArray();
$rules = collect($rules)->only($properties)->toArray();
$messages = collect($messages)->only($properties)->toArray();
$attributes = collect($attributes)->only($properties)->toArray();
return Validator::make($state, $rules, $messages, $attributes);
}
public function rules()
{
return $this->rules ?? [];
}
public function messages()
{
return [];
}
public function attributes()
{
return [];
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace Airwire\Http;
use Airwire\Airwire;
use Airwire\Component;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Validator;
class AirwireController
{
public function __invoke(Request $request, string $component, string $target = null)
{
return response()->json($this->response($component, $request->input(), $target));
}
public function response(string $component, array $input, string $target = null): array
{
$validator = $this->validator($input + ['component' => $component]);
if ($validator->fails()) {
return [
'data' => $input['state'] ?? [],
'metadata' => [
'errors' => $validator->errors(),
],
];
}
return $this->makeComponent($component, $input['state'] ?? [], $target)
->handle($input['changes'] ?? [], $input['calls'] ?? [], $target)
->response();
}
public function makeComponent(string $component, array $state): Component
{
return new Airwire::$components[$component]($state);
}
protected function validator(array $data)
{
return Validator::make($data, [
'component' => ['required', function ($attribute, $value, $fail) {
if (! Airwire::hasComponent($value)) {
$fail("Component {$value} not found.");
}
}],
'state' => ['nullable', function ($attribute, $value, $fail) {
if (! is_array($value)) $fail('State must be an array.');
foreach ($value as $k => $v) {
if (! is_string($k)) $fail("[State] Property name must be a string, {$k} given.");
}
}],
'changes' => ['nullable', function ($attribute, $value, $fail) {
if (! is_array($value)) $fail('Changes must be an array.');
foreach ($value as $k => $v) {
if (! is_string($k)) $fail("[Changes] Property name must be a string, {$k} given.");
}
}],
'calls' => ['nullable', function ($attribute, $value, $fail) {
if (! is_array($value)) $fail('Calls must be an array.');
foreach ($value as $k => $v) {
if (! is_string($k)) $fail("[Calls] Method name must be a string, {$k} given.");
}
}],
]);
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace Airwire\Testing;
class AirwireResponse
{
public array $data;
public ResponseMetadata $metadata;
public function __construct(
protected array $rawResponse
) {
$rawResponse['metadata'] ??= [];
$this->data = ($rawResponse['data'] ?? []);
$this->metadata = new ResponseMetadata(
$rawResponse['metadata']['calls'] ?? [],
$rawResponse['metadata']['errors'] ?? [],
$rawResponse['metadata']['exceptions'] ?? [],
);
}
public function json(string $key, mixed $default = null): mixed
{
return $this->get($key, $default);
}
public function get(string $key, mixed $default = null): mixed
{
$target = str_starts_with($key, 'metadata.')
? $this->metadata
: $this->data;
return data_get($target, $key, $default);
}
public function data(string $key, mixed $default = null): mixed
{
return data_get($this->data, $key, $default);
}
public function metadata(string $key, mixed $default = null): mixed
{
return data_get($this->metadata, $key, $default);
}
public function errors(string $property = null): mixed
{
if ($property) {
return $this->metadata->errors[$property] ?? [];
}
return $this->metadata->errors;
}
public function exceptions(string $method = null): mixed
{
if ($method) {
return $this->metadata->exceptions[$method] ?? [];
}
return $this->metadata->exceptions;
}
/** Get the return value of a call. */
public function call(string $key): mixed
{
return $this->metadata->calls[$key] ?? null;
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace Airwire\Testing;
use Airwire\Airwire;
use Airwire\Component;
use Airwire\Http\AirwireController;
class RequestBuilder
{
public function __construct(
public string $alias,
) {}
public string $target = 'test';
public array $state = [];
public array $changes = [];
public array $calls = [];
public function state(array $state): static
{
$this->state = $state;
return $this;
}
public function call(string $method, mixed ...$arguments): static
{
$this->calls[$method] = $arguments;
$this->target = $method;
return $this;
}
public function changes(array $changes): static
{
$this->changes = $changes;
return $this;
}
public function change(string $key, mixed $value): static
{
$this->changes[$key] = $value;
$this->target = $key;
return $this;
}
public function send(): AirwireResponse
{
return new AirwireResponse((new AirwireController)->response($this->alias, [
'state' => $this->state,
'changes' => $this->changes,
'calls' => $this->calls,
], $this->target));
}
public function hydrate(): Component
{
return (new AirwireController)->makeComponent($this->alias, $this->state)
->handle($this->changes, $this->calls, $this->target);
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace Airwire\Testing;
class ResponseMetadata
{
/** Results of method calls. */
public array $calls;
/**
* Validation errors.
*
* @var array<string, string[]>
*/
public array $errors;
/**
* Exceptions occured during the execution of individual methods..
*
* @var array<string, array>
*/
public array $exceptions;
public function __construct(array $calls, array $errors, array $exceptions)
{
$this->calls = $calls;
$this->errors = $errors;
$this->exceptions = $exceptions;
}
}

241
src/TypehintConverter.php Normal file
View file

@ -0,0 +1,241 @@
<?php
namespace Airwire;
use Airwire\Attributes\Wired;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Collection;
use Illuminate\Support\Enumerable;
use Illuminate\Support\Facades\Schema;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionObject;
use ReflectionProperty;
use ReflectionUnionType;
class TypehintConverter
{
public array $namedTypes = [];
public function convertBuiltinType(string $type): string
{
return match ($type) {
'int' => 'number',
'float' => 'number',
'string' => 'string',
'array' => 'any', // Arrays can be associative, so they're essentially objects
'object' => 'any',
'null' => 'null',
default => 'any',
};
}
public function convertType(string $php): string
{
if (class_exists($php)) {
if (is_subclass_of($php, Model::class) && ($model = $php::first())) {
return $this->convertModel($model);
}
if (is_subclass_of($php, Collection::class) && ($model = $php::first())) {
return 'array';
}
return 'any';
}
return $this->convertBuiltinType($php);
}
public function typeFromValue(mixed $value): string
{
return match (true) {
$value instanceof Model => $this->convertModel($value),
$value instanceof Collection => 'array',
default => $this->convertBuiltinType(gettype($value)),
};
}
public function convertModel(Model $model): string
{
$alias = $this->getClassName($model);
if (! isset($this->namedTypes[$alias])) {
$this->namedTypes[$alias] = 'pending'; // We do this to avoid infinite loops when recursively generating model type definitions
$values = $model->toArray()
?: $model->first()->toArray() // If this model is empty, attempt finding the first one in the DB
?: collect(Schema::getColumnListing($model->getTable()))->mapWithKeys(fn (string $column) => [$column => []])->toArray(); // [] for any
$this->namedTypes[$alias] = '{ ' .
collect($values)
->map(fn (mixed $value) => $this->typeFromValue($value))
->map(function (string $type, string $property) use ($model) {
if ($model->getKeyName() !== $property) {
// Don't do anything
return $type;
}
if ($type === 'any' && $model->getIncrementing()) {
$type = 'number';
}
return $type;
})
->merge($this->getModelRelations($model))
->map(fn (string $type, string $property) => "{$property}: {$type}")->join(', ')
. ' }';
}
return $alias;
}
public function getModelRelations(Model $model): array
{
$loaded = collect($model->getRelations())
->map(fn ($value) => $value instanceof Enumerable ? $value->first() : $value) // todo plural relations are incorrectly typed - should be e.g. Report[]
->filter(fn ($value) => $value instanceof Model);
/** @var Collection<string, Model> */
$reflected = collect((new ReflectionObject($model))->getMethods())
->keyBy(fn (ReflectionMethod $method) => $method->getName())
->filter(fn (ReflectionMethod $method) => $method->getReturnType() && is_subclass_of($method->getReturnType()->getName(), Relation::class)) // todo support this even without typehints
->map(fn (ReflectionMethod $method, string $name) => $model->$name()->getRelated())
->filter(fn ($value, $relation) => ! $loaded->has($relation)); // Ignore relations that we could find using getRelations()
$relations = $loaded->merge($reflected);
return $relations->map(fn (Model $model) => $this->convertModel($model))->toArray();
}
public function getClassName(object|string $class): string
{
if (is_object($class)) {
$class = $class::class;
}
return last(explode('\\', $class));
}
public function convertComponent(Component $component): string
{
$properties = $component->getSharedProperties();
$methods = $component->getSharedMethods();
$tsProperties = [];
$tsMethods = [];
foreach ($properties as $property) {
$tsProperties[$property] = $this->convertProperty($component, $property);
}
foreach ($methods as $method) {
$tsMethods[] = $this->convertMethod($component, $method);
}
$definition = '';
$class = $this->getClassName($component);
$definition .= "interface {$class} {\n";
foreach ($tsProperties as $property => $type) {
$definition .= " {$property}: {$type};\n";
}
foreach ($tsMethods as $signature) {
$definition .= " {$signature}\n";
}
$definition .= <<<TS
errors: {
[key in keyof WiredProperties<{$class}>]: string[];
}
loading: boolean;
watch(responses: (response: ComponentResponse<{$class}>) => void, errors?: (error: AirwireException) => void): void;
defer(callback: CallableFunction): void;
refresh(): ComponentResponse<{$class}>;
remount(...args: any): ComponentResponse<{$class}>;
readonly: {$class};
deferred: {$class};
\$component: {$class};
}
TS;
return $definition;
}
public function convertProperty(object $object, string $property): string
{
$reflection = new ReflectionProperty($object, $property);
if ($wired = optional($reflection->getAttributes(Wired::class))[0]) {
if ($type = $wired->newInstance()->type) {
return $type;
}
}
$type = $reflection->getType();
if ($type instanceof ReflectionUnionType) {
$types = $type->getTypes();
} else {
$types = [$type];
}
if ($type->allowsNull()) {
$types[] = 'null';
}
$results = [];
foreach ($types as $type) {
// If we're working with a union type, some types are only accessible
// from the typehint, but for one type we'll also have the value.
if (isset($object->$property) && gettype($object->$property) === $type->getName()) {
$results[] = $this->typeFromValue($object->$property);
} else {
$results[] = $this->convertType($type->getName());
}
}
return join(' | ', $results);
}
public function convertMethod(object $object, string $method): string
{
$reflection = new ReflectionMethod($object, $method);
$parameters = [];
foreach ($reflection->getParameters() as $parameter) {
$type = $parameter->getType();
if ($type instanceof ReflectionUnionType) {
$types = $type->getTypes();
} else {
$types = [$type];
}
if ($type->allowsNull()) {
$types[] = 'null';
}
$parameters[$parameter->getName()] = join(' | ', array_map(fn (ReflectionNamedType $type) => $this->convertType($type->getName()), $types));
}
$parameters = collect($parameters)->map(fn (string $type, string $name) => "{$name}: {$type}")->join(', ');
$return = match ($type = $reflection->getReturnType()) {
null => 'any',
default => $this->convertType($type),
};
return "{$method}(" . $parameters . "): AirwirePromise<{$return}>;";
}
}

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()
}

45
tests/Pest.php Normal file
View file

@ -0,0 +1,45 @@
<?php
/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "uses()" function to bind a different classes or traits.
|
*/
uses(Airwire\Tests\TestCase::class)->in('Airwire');
/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/
expect()->extend('toBeOne', function () {
return $this->toBe(1);
});
/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/
function something()
{
// ..
}

16
tests/TestCase.php Normal file
View file

@ -0,0 +1,16 @@
<?php
namespace Airwire\Tests;
use Airwire\AirwireServiceProvider;
use Orchestra\Testbench\TestCase as TestbenchTestCase;
class TestCase extends TestbenchTestCase
{
protected function getPackageProviders($app)
{
return [
AirwireServiceProvider::class,
];
}
}

12
tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2017",
"strict": true,
"module": "es2015",
"moduleResolution": "node",
"experimentalDecorators": true,
"sourceMap": true,
"skipLibCheck": true
},
"include": ["resources/js/**/*"], "typeAcquisition": {"include": ["resources/js/helperTypes.d.ts"]}
}