From d26fa93f1e8c6fb5caa7d3490ac210c793ede27e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 20 May 2021 20:15:55 +0200 Subject: [PATCH] initial --- .gitignore | 5 + LICENSE | 21 ++ README.md | 0 composer.json | 32 +++ package.json | 5 + phpunit.xml | 25 +++ resources/js/AirwireWatcher.js | 23 ++ resources/js/_types.d.ts | 13 ++ resources/js/airwired.ts | 60 ++++++ resources/js/index.ts | 311 +++++++++++++++++++++++++++ resources/js/plugins/alpine.ts | 19 ++ resources/js/plugins/vue.ts | 6 + routes/airwire.php | 6 + src/Airwire.php | 191 ++++++++++++++++ src/AirwireServiceProvider.php | 56 +++++ src/Attributes/Encode.php | 32 +++ src/Attributes/Wired.php | 15 ++ src/Commands/GenerateDefinitions.php | 72 +++++++ src/Component.php | 65 ++++++ src/Concerns/ManagesActions.php | 122 +++++++++++ src/Concerns/ManagesLifecycle.php | 57 +++++ src/Concerns/ManagesState.php | 96 +++++++++ src/Concerns/ManagesValidation.php | 84 ++++++++ src/Http/AirwireController.php | 71 ++++++ src/Testing/AirwireResponse.php | 71 ++++++ src/Testing/RequestBuilder.php | 67 ++++++ src/Testing/ResponseMetadata.php | 30 +++ src/TypehintConverter.php | 241 +++++++++++++++++++++ tests/Airwire/ComponentTest.php | 90 ++++++++ tests/Airwire/TypeScriptTest.php | 6 + tests/Airwire/TypehintsTest.php | 203 +++++++++++++++++ tests/Airwire/ValidationTest.php | 220 +++++++++++++++++++ tests/Pest.php | 45 ++++ tests/TestCase.php | 16 ++ tsconfig.json | 12 ++ 35 files changed, 2388 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 package.json create mode 100644 phpunit.xml create mode 100644 resources/js/AirwireWatcher.js create mode 100644 resources/js/_types.d.ts create mode 100644 resources/js/airwired.ts create mode 100644 resources/js/index.ts create mode 100644 resources/js/plugins/alpine.ts create mode 100644 resources/js/plugins/vue.ts create mode 100644 routes/airwire.php create mode 100644 src/Airwire.php create mode 100644 src/AirwireServiceProvider.php create mode 100644 src/Attributes/Encode.php create mode 100644 src/Attributes/Wired.php create mode 100644 src/Commands/GenerateDefinitions.php create mode 100644 src/Component.php create mode 100644 src/Concerns/ManagesActions.php create mode 100644 src/Concerns/ManagesLifecycle.php create mode 100644 src/Concerns/ManagesState.php create mode 100644 src/Concerns/ManagesValidation.php create mode 100644 src/Http/AirwireController.php create mode 100644 src/Testing/AirwireResponse.php create mode 100644 src/Testing/RequestBuilder.php create mode 100644 src/Testing/ResponseMetadata.php create mode 100644 src/TypehintConverter.php create mode 100644 tests/Airwire/ComponentTest.php create mode 100644 tests/Airwire/TypeScriptTest.php create mode 100644 tests/Airwire/TypehintsTest.php create mode 100644 tests/Airwire/ValidationTest.php create mode 100644 tests/Pest.php create mode 100644 tests/TestCase.php create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7137bf2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor/ +composer.lock +node_modules +package-lock.json +.phpunit.result.cache diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e738801 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2fe33f5 --- /dev/null +++ b/composer.json @@ -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" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6e92337 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "@types/node": "^15.3.1" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..2d86438 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,25 @@ + + + + + ./tests/Airwire + + + + + ./app + + + + + + + + + + + + + + + diff --git a/resources/js/AirwireWatcher.js b/resources/js/AirwireWatcher.js new file mode 100644 index 0000000..3150c18 --- /dev/null +++ b/resources/js/AirwireWatcher.js @@ -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; diff --git a/resources/js/_types.d.ts b/resources/js/_types.d.ts new file mode 100644 index 0000000..cb9b35f --- /dev/null +++ b/resources/js/_types.d.ts @@ -0,0 +1,13 @@ +import './airwired' + +declare module 'airwire' { + export interface TypeMap { + String: any; + } +} + +declare global { + interface Window { + Airwire: Airwire + } +} diff --git a/resources/js/airwired.ts b/resources/js/airwired.ts new file mode 100644 index 0000000..a7c86b4 --- /dev/null +++ b/resources/js/airwired.ts @@ -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 extends Promise { + then(onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: AirwireException) => TResult2 | PromiseLike) | undefined | null): AirwirePromise; + catch(onrejected?: ((reason: AirwireException) => TResult | PromiseLike) | undefined | null): AirwirePromise; + } + + export type NonFunctionPropertyNames = { [K in keyof T]: T[K] extends Function ? never : K }[keyof T]; + export type NonFunctionProperties = Pick>; + export type FunctionProperties = Omit>; + export type StringKeys = Pick>; + export type WiredMethods = StringKeys>>>; + export type WiredProperties = StringKeys>>>; + export type Magic = T & { [key: string]: any }; + + export type ComponentResponse = { + data: WiredProperties; + metadata: { + calls?: { + [key in keyof WiredMethods]: any; + }; + + exceptions?: { + [key in keyof WiredMethods]: AirwireException; + }; + + errors?: { + [key in keyof WiredProperties]: string[]; + }; + + readonly: Array>; + + [key: string]: any; + } + } + + type Watchers = { + responses: Array<(response: ComponentResponse) => void>, + errors: Array<(error: AirwireException) => void>, + }; + + export type TypeNames = keyof TypeMap + + export type TypeName = T extends TypeNames + ? TypeMap[T] + : never +} diff --git a/resources/js/index.ts b/resources/js/index.ts new file mode 100644 index 0000000..e7aa8a9 --- /dev/null +++ b/resources/js/index.ts @@ -0,0 +1,311 @@ +import { TypeMap, Watchers, WiredProperties, WiredMethods, AirwireException, AirwirePromise, ComponentResponse, Magic, TypeName, TypeNames } from 'airwire' + +export class Component +{ + public proxy: any; + + public loading: boolean = false; + + public errors: Record = {}; + + public _proxyTarget: Component; + + public watchers: Watchers = { responses: [], errors: [] }; + + public pausedRequests: boolean = false; + public pendingChanges: Partial<{ [key in keyof WiredProperties]: any }> = {}; + public pendingCalls: Partial<{ [key in keyof WiredMethods]: any }> = {}; + + public readonly: Partial<{ [key in keyof WiredProperties]: 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 = 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] = 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]; + } + + if (typeof property === 'string' && !property.startsWith('__v') && property !== 'toJSON') { + return function (...args: any[]) { + return component.call.apply(component, [ + property as keyof WiredMethods, + ...args + ]); + } + } + }, + + set(target, property: string, value) { + component.update(property as keyof WiredProperties, value); + + return true + } + }) + } + + public update(property: keyof WiredProperties, value: any): Promise> | null { + this.state[property] = value; + + if (this.pausedRequests) { + this.pendingChanges[property] = value; + + return null; + } + + return this.request(property, { + changes: { [property]: value } + }, (json: ComponentResponse) => { + if (json?.metadata?.exceptions) { + return Promise.reject(json.metadata.exceptions); + } + + return json + }) + } + + public call(method: keyof WiredMethods, ...args: any[]): AirwirePromise | null { + if (this.pausedRequests) { + this.pendingCalls[method] = args; + + return null; + } + + return this.request(method, { + calls: { [method]: args } + }, (json: ComponentResponse) => { + if (json?.metadata?.exceptions) { + return Promise.reject(json.metadata.exceptions[method] ?? json.metadata.exceptions); + } + + return json; + }).then((json: ComponentResponse) => json?.metadata?.calls?.[method] ?? null); + } + + public request(target: string, data: { + calls?: { [key in string]: any[] }, + changes?: { [key in string]: any }, + }, callback: (json: ComponentResponse) => any = (json: ComponentResponse) => json): AirwirePromise> { + 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) => { + 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) => { // 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] = 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) => void, errors?: (error: AirwireException) => void): void { + this.watchers.responses.push(responses); + + if (errors) { + this.watchers.errors.push(errors); + } + } + + public defer(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 = { responses: [], errors: [] }; + + public components: Partial<{ [T in keyof TypeMap]: Array> }> = {}; + + public constructor( + public componentDefaults: any = {}, + public reactive: CallableFunction = (component: Component) => component, + ) { } + + public watch(responses: (response: ComponentResponse) => 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 | null = null): void { + this.refresh(aliases, true) + } + + public refresh(aliases: keyof TypeMap | Array | null = null, remount: boolean = false): void { + if (typeof aliases === 'string') { + aliases = [aliases]; + } + + if (! aliases) { + aliases = Object.keys(this.components) as Array; + } + + for (const alias of aliases) { + this.components[alias]?.forEach((component: Component) => { + if (remount) { + component.remount(); + } else { + component.refresh(); + } + }); + } + + } + + public component(alias: K, state: WiredProperties>, reactive: CallableFunction | null = null): Magic> { + const component = new Component>(alias, { + ...this.componentDefaults[alias] ?? {}, + ...state + }, reactive); + + return component.proxy as TypeName; + } + + public plugin(name: string) { + return require('./plugins/' + name).default; + } +} + +export default Airwire; diff --git a/resources/js/plugins/alpine.ts b/resources/js/plugins/alpine.ts new file mode 100644 index 0000000..810f5c5 --- /dev/null +++ b/resources/js/plugins/alpine.ts @@ -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) + } +} diff --git a/resources/js/plugins/vue.ts b/resources/js/plugins/vue.ts new file mode 100644 index 0000000..8dd1995 --- /dev/null +++ b/resources/js/plugins/vue.ts @@ -0,0 +1,6 @@ +export default (reactive: any) => ({ + install(app: any) { + window.Airwire.reactive = reactive; + app.config.globalProperties.$airwire = window.Airwire + } +}) diff --git a/routes/airwire.php b/routes/airwire.php new file mode 100644 index 0000000..e46b565 --- /dev/null +++ b/routes/airwire.php @@ -0,0 +1,6 @@ +name('airwire.component'); diff --git a/src/Airwire.php b/src/Airwire.php new file mode 100644 index 0000000..f89a0bb --- /dev/null +++ b/src/Airwire.php @@ -0,0 +1,191 @@ + 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'; + } +} diff --git a/src/AirwireServiceProvider.php b/src/AirwireServiceProvider.php new file mode 100644 index 0000000..a59225f --- /dev/null +++ b/src/AirwireServiceProvider.php @@ -0,0 +1,56 @@ +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(), + ); + } +} diff --git a/src/Attributes/Encode.php b/src/Attributes/Encode.php new file mode 100644 index 0000000..45e1dff --- /dev/null +++ b/src/Attributes/Encode.php @@ -0,0 +1,32 @@ +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; + } +} diff --git a/src/Attributes/Wired.php b/src/Attributes/Wired.php new file mode 100644 index 0000000..aca5eca --- /dev/null +++ b/src/Attributes/Wired.php @@ -0,0 +1,15 @@ + $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'), <<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); + } +} diff --git a/src/Concerns/ManagesActions.php b/src/Concerns/ManagesActions.php new file mode 100644 index 0000000..61cf798 --- /dev/null +++ b/src/Concerns/ManagesActions.php @@ -0,0 +1,122 @@ +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; + } + } +} diff --git a/src/Concerns/ManagesLifecycle.php b/src/Concerns/ManagesLifecycle.php new file mode 100644 index 0000000..776eb11 --- /dev/null +++ b/src/Concerns/ManagesLifecycle.php @@ -0,0 +1,57 @@ +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 + { + } +} diff --git a/src/Concerns/ManagesState.php b/src/Concerns/ManagesState.php new file mode 100644 index 0000000..479f756 --- /dev/null +++ b/src/Concerns/ManagesState.php @@ -0,0 +1,96 @@ +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; + } + } +} diff --git a/src/Concerns/ManagesValidation.php b/src/Concerns/ManagesValidation.php new file mode 100644 index 0000000..d6d3503 --- /dev/null +++ b/src/Concerns/ManagesValidation.php @@ -0,0 +1,84 @@ +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 []; + } +} diff --git a/src/Http/AirwireController.php b/src/Http/AirwireController.php new file mode 100644 index 0000000..dfd1014 --- /dev/null +++ b/src/Http/AirwireController.php @@ -0,0 +1,71 @@ +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."); + } + }], + ]); + } +} diff --git a/src/Testing/AirwireResponse.php b/src/Testing/AirwireResponse.php new file mode 100644 index 0000000..f4054a8 --- /dev/null +++ b/src/Testing/AirwireResponse.php @@ -0,0 +1,71 @@ +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; + } +} diff --git a/src/Testing/RequestBuilder.php b/src/Testing/RequestBuilder.php new file mode 100644 index 0000000..cac3488 --- /dev/null +++ b/src/Testing/RequestBuilder.php @@ -0,0 +1,67 @@ +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); + } +} diff --git a/src/Testing/ResponseMetadata.php b/src/Testing/ResponseMetadata.php new file mode 100644 index 0000000..e28f96c --- /dev/null +++ b/src/Testing/ResponseMetadata.php @@ -0,0 +1,30 @@ + + */ + public array $errors; + + /** + * Exceptions occured during the execution of individual methods.. + * + * @var array + */ + public array $exceptions; + + public function __construct(array $calls, array $errors, array $exceptions) + { + $this->calls = $calls; + $this->errors = $errors; + $this->exceptions = $exceptions; + } +} diff --git a/src/TypehintConverter.php b/src/TypehintConverter.php new file mode 100644 index 0000000..5e6a24b --- /dev/null +++ b/src/TypehintConverter.php @@ -0,0 +1,241 @@ + '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 */ + $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 .= <<]: 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}>;"; + } +} diff --git a/tests/Airwire/ComponentTest.php b/tests/Airwire/ComponentTest.php new file mode 100644 index 0000000..8b393af --- /dev/null +++ b/tests/Airwire/ComponentTest.php @@ -0,0 +1,90 @@ + 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'); + } +} diff --git a/tests/Airwire/TypeScriptTest.php b/tests/Airwire/TypeScriptTest.php new file mode 100644 index 0000000..5bad18c --- /dev/null +++ b/tests/Airwire/TypeScriptTest.php @@ -0,0 +1,6 @@ +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, + ) {} +} diff --git a/tests/Airwire/ValidationTest.php b/tests/Airwire/ValidationTest.php new file mode 100644 index 0000000..000cf60 --- /dev/null +++ b/tests/Airwire/ValidationTest.php @@ -0,0 +1,220 @@ +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() +} diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..1e3b38a --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,45 @@ +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() +{ + // .. +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..4dbf180 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,16 @@ +