From 28e16d6bef376dd92f0ef3ee89bc75c88ca52b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 20 May 2021 22:38:02 +0200 Subject: [PATCH] write readme --- README.md | 570 +++++++++++++++++++++++++++ resources/js/AirwireWatcher.js | 6 +- resources/js/_types.d.ts | 1 + src/AirwireServiceProvider.php | 3 +- src/Commands/ComponentCommand.php | 41 ++ src/Commands/GenerateDefinitions.php | 2 +- tests/Airwire/TypehintsTest.php | 1 + 7 files changed, 619 insertions(+), 5 deletions(-) create mode 100644 src/Commands/ComponentCommand.php diff --git a/README.md b/README.md index e69de29..8d7ec3b 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,570 @@ +# Airwire + +*A lightweight full-stack component layer that doesn't dictate your front-end framework* + +## Introduction + +Airwire is a thin layer between your Laravel code and your JavaScript. + +It lets you write Livewire-style OOP components like this: + +```php +class CreateUser extends Component +{ + #[Wired] + public string $name = ''; + + #[Wired] + public string $email = ''; + + #[Wired] + public string $password = ''; + + #[Wired] + public string $password_confirmation = ''; + + public function rules() + { + return [ + 'name' => ['required', 'min:5', 'max:25', 'unique:users'], + 'email' => ['required', 'unique:users'], + 'password' => ['required', 'min:8', 'confirmed'], + ]; + } + + #[Wired] + public function submit(): User + { + $user = User::create($this->validated()); + + $this->meta('notification', __('users.created', ['id' => $user->id, 'name' => $user->name])); + + $this->reset(); + + return $user; + } +} +``` + +Then, it generates a TypeScript definition like this: + +```ts +interface CreateUser { + name: string; + email: string; + password: string; + password_confirmation: string; + create(): AirwirePromise; + errors: { ... } + + // ... +} +``` + +And Airwire will wire the two parts together. It's up to you what frontend framework you use (if any), Airwire will simply forward calls and sync state between the frontend and the backend. + +The most basic use of Airwire would look like this: + +```ts +let component = Airwire.component('create-user') + +console.log(component.name); // your IDE knows that this is a string + +component.name = 'foo'; + +component.errors; // { name: ['The name must be at least 10 characters.'] } + +// No point in making three requests here, so let's defer the changes +component.deferred.name = 'foobar'; +component.deferred.password = 'secret123'; +component.deferred.password_confirmation = 'secret123'; + +// Watch all received responses +component.watch(response => { + if (response.metadata.notification) { + alert(response.metadata.notification) + } +}) + +component.submit().then(user => { + // TS knows the exact data structure of 'user' + console.log(user.created_at); +}) +``` + +## Installation + +*Laravel 8 and PHP 8 are needed.* + +First install the package via composer: +``` +composer require archtechx/airwire +``` + +Then go to your `webpack.mix.js` and register the watcher plugin. It will refresh the TypeScript definitions whenever you make a change to PHP code: + +```js +mix.webpackConfig({ + plugins: [ + new (require('./vendor/archtechx/airwire/resources/js/AirwireWatcher'))(require('chokidar')), + ], +}) +``` + +Next, run `php artisan airwire:generate` to generate the initial TS files. This will create `airwire.ts` and `airwired.d.ts`. Open your `app.ts` and the former: + +```ts +import Airwire from './airwire' +``` + +And that's all! Airwire is fully installed. + +## PHP components + +### Creating components + +To create a component run the `php artisan airwire:component` command. + +``` +php artisan airwire:component CreateUser +``` + +The command in the example will create a file in `app/Airwire/CreateUser.php`. + +Next, register it in your AppServiceProvider: + +```php +// boot() + +Airwire::component('create-user', CreateUser::class); +``` + +### Wired properties and methods + +Component properties and methods will be shared with the frontend if they use the `#[Wired]` attribute (in contrast to Livewire, where `public` visibility is used for this). + +This means that your components can use properties (even public) just fine, and they won't be shared with the frontend until you explicitly add this attribute. + +```php +class CreateTeam extends Component +{ + #[Wired] + public string $name; // Shared + + public string $owner; // Not shared + + public function hydrate() + { + $this->owner = auth()->id(); + } +} +``` + +### Lifecycle hooks + +As showed in the example above, Airwire has useful lifecycle hooks: + +```php +public function hydrate() +{ + // Executed on each request, before any changes & calls are made +} + +public function dehydrate() +{ + // Executed when serving a response, before things like validation errors are serialized into array metadata +} + +public function updating(string $property, mixed $value): bool +{ + return false; // disallow this state change +} + +public function updatingFoo(mixed $value): bool +{ + return true; // allow this state change +} + +public function updated(string $property, mixed $value): void +{ + // execute side effects as a result of a state change +} + +public function updatedFoo(mixed $value): void +{ + // execute side effects as a result of a state change +} + +public function changed(array $changes): void +{ + // execute side effects $changes has a list of properties that were changed + // i.e. passed validation and updating() hooks +} +``` + +### Validation + +Airwire components use **strict validation** by default. This means that no calls can be made if the provided data is invalid. + +To disable strict validation, set this property to false: +```php +public bool $strictValidation = false; +``` + +Note that disabling strict validation means that you're fully responsible for validating all incoming input before making any potentially dangerous calls, such as database queries. + +```php +public array $rules = [ + 'name' => ['required', 'string', 'max:100'], +]; + +// or ... +public function rules() +{ + return [ ... ]; +} + +public function messages() +{ + return [ ... ]; +} + +public function attributes() +{ + return [ ... ]; +} +``` + +### Custom types + +Airwire supports custom DTOs. Simply tell it how to decode (incoming requests) and encode (outgoing responses) the data: + +```php +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], +); +``` + +This doesn't require changes to the DTO class, and it works with any classes that extend the class. + +### Models + +A type transformer for models is included by default. It uses the `toArray()` method to generate a JSON-friendly representation of the model (which means that things like `$hidden` are respected). + +It supports converting received IDs to model instances: +```php +// received: '3' +public User $user; +``` + +Converting arays/objects to unsaved instances: +```php +// received: ['name' => 'Try Airwire on a new project', 'priority' => 'highest'] +public function addTask(Task $task) +{ + $task->save(); +} +``` + +Converting properties/return values to arrays: +```php +public User $user; +// response: {"name": "John Doe", "email": "john@example.com", ... } + +public find(string $id): Response +{ + return User::find($id); +} +// same response as the property +``` + +If you wish to have even more control over how the data should be encoded, on a property-by-property basis, you can add a `Decoded` attribute. This can be useful for returning the id of a model, even if a property holds its instance: +```php +#[Wired] #[Encode(method: 'getKey')] +public User $user; // returns '3' + +#[Wired] #[Encode(property: 'slug')] +public Post $post; // returns 'introducing-airwire' + +#[Wired] #[Encode(function: 'generateHashid')] +public Post $post; // returns the value of generateHashid($post) +``` + +### Default values + +You can specify default values for properties that can't have them specified directly in the class: + +```php +#[Wired(default: [])] +public Collection $results; +``` + +These values will be part of the generated JS files, which means that components will have correct initial state even if they're initialized purely on the frontend, before making a single request to the server. + +### Readonly values + +Properties can also be readonly. This tells the frontend not to send them to the backend in request data. + +A good use case for readonly properties is data that's only written by the server, e.g. query results: + +```php +// Search/Filter component + +#[Wired(readonly: true, default: [])] +public Collection $results; +``` + +### Mounting components + +Components can have a `mount()` method, which returns initial state. This state is not accessible when the component is instantiated on the frontend (unlike default values of properties), so the component requests the data from the server. + +A good use case for `mount()` is `