mirror of
https://github.com/archtechx/airwire.git
synced 2025-12-12 02:34:04 +00:00
initial
This commit is contained in:
commit
d26fa93f1e
35 changed files with 2388 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/vendor/
|
||||
composer.lock
|
||||
node_modules
|
||||
package-lock.json
|
||||
.phpunit.result.cache
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
0
README.md
Normal file
32
composer.json
Normal file
32
composer.json
Normal 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
5
package.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"@types/node": "^15.3.1"
|
||||
}
|
||||
}
|
||||
25
phpunit.xml
Normal file
25
phpunit.xml
Normal 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>
|
||||
23
resources/js/AirwireWatcher.js
Normal file
23
resources/js/AirwireWatcher.js
Normal 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
13
resources/js/_types.d.ts
vendored
Normal 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
60
resources/js/airwired.ts
Normal 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
311
resources/js/index.ts
Normal 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;
|
||||
19
resources/js/plugins/alpine.ts
Normal file
19
resources/js/plugins/alpine.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
6
resources/js/plugins/vue.ts
Normal file
6
resources/js/plugins/vue.ts
Normal 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
6
routes/airwire.php
Normal 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
191
src/Airwire.php
Normal 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';
|
||||
}
|
||||
}
|
||||
56
src/AirwireServiceProvider.php
Normal file
56
src/AirwireServiceProvider.php
Normal 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
32
src/Attributes/Encode.php
Normal 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
15
src/Attributes/Wired.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
72
src/Commands/GenerateDefinitions.php
Normal file
72
src/Commands/GenerateDefinitions.php
Normal 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
65
src/Component.php
Normal 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);
|
||||
}
|
||||
}
|
||||
122
src/Concerns/ManagesActions.php
Normal file
122
src/Concerns/ManagesActions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/Concerns/ManagesLifecycle.php
Normal file
57
src/Concerns/ManagesLifecycle.php
Normal 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
|
||||
{
|
||||
}
|
||||
}
|
||||
96
src/Concerns/ManagesState.php
Normal file
96
src/Concerns/ManagesState.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
84
src/Concerns/ManagesValidation.php
Normal file
84
src/Concerns/ManagesValidation.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
71
src/Http/AirwireController.php
Normal file
71
src/Http/AirwireController.php
Normal 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.");
|
||||
}
|
||||
}],
|
||||
]);
|
||||
}
|
||||
}
|
||||
71
src/Testing/AirwireResponse.php
Normal file
71
src/Testing/AirwireResponse.php
Normal 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;
|
||||
}
|
||||
}
|
||||
67
src/Testing/RequestBuilder.php
Normal file
67
src/Testing/RequestBuilder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
30
src/Testing/ResponseMetadata.php
Normal file
30
src/Testing/ResponseMetadata.php
Normal 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
241
src/TypehintConverter.php
Normal 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}>;";
|
||||
}
|
||||
}
|
||||
90
tests/Airwire/ComponentTest.php
Normal file
90
tests/Airwire/ComponentTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
6
tests/Airwire/TypeScriptTest.php
Normal file
6
tests/Airwire/TypeScriptTest.php
Normal 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
|
||||
203
tests/Airwire/TypehintsTest.php
Normal file
203
tests/Airwire/TypehintsTest.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
220
tests/Airwire/ValidationTest.php
Normal file
220
tests/Airwire/ValidationTest.php
Normal 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
45
tests/Pest.php
Normal 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
16
tests/TestCase.php
Normal 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
12
tsconfig.json
Normal 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"]}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue