Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `config/hooks.php`, published with `php artisan vendor:publish --tag=pollora-hooks`: default driver (`HOOKS_ASYNC_DRIVER`, `sync` in a developer's `.env` runs every handler at once), attempts, backoff, `as_user`, queue connection and name
- `#[Async]`, next to `#[Action]`, makes a method asynchronous with the options of `->async()` as named parameters: `delay`, `via`, `onQueue`, `unique` (`true` or the lock duration), `tries`, `backoff`, `asUser`, `capture` and `when` (public methods of the class), `keepMissing`, `except`. On a class it applies to every `#[Action]` method, and a method's own `#[Async]` replaces it. A declaration that cannot be honoured (a hook in `except` the method does not declare, a missing or non-public `capture`/`when` method, invalid attempts, backoff or lock) is logged and the action runs synchronously; `#[Async]` on a `#[Filter]` or without `#[Action]` is logged
- `pollora:doctor` and Site Health check asynchronous actions (`async-actions`): an ignored `#[Async]` (now kept, not only logged), an unavailable default driver (every handler then runs in the request) or `via()` driver, a `HOOKS_ASYNC_CONNECTION` that is undefined or `sync`/`null`, WP-Cron events overdue for an hour while WP-Cron or Action Scheduler is in use (Pollora sets `DISABLE_WP_CRON`: a system cron must request `wp-cron.php`), jobs of a database queue waiting 15 minutes for a worker, and payloads or unique locks the daily recovery task left behind
- `pollora:make:action --async` generates the method with `#[Async]` and its import, in a new class or added to an existing one
- Async handlers in the framework: debug mode follows `app.debug`, incidents go to the Laravel log, closures are signed with the application key, Eloquent models travel by class and key and are reloaded at execution, and parameters typed with a service are resolved from the container

### Fixed
- `pollora:make:action` and `pollora:make:filter` were registered as `pollora:make:action {name}` and `pollora:make:filter {name}`: the console reached them by prefix, but `Artisan::call('pollora:make:action')` did not find them
- The method generated for a hook with separators read `handleSavepost` for `save_post`: it is now `handleSavePost`
- Errors met while registering hooks discovered by attribute went to no logger: `HookDiscovery` now receives the application's logger
- Login screen: the language switcher's label ("Language" and its icon, shown when a second language is installed) sat at the left edge of the window, far from its dropdown. The card style given to the login form also reached the switcher's form and made it a full-width block; it is an inline block again, as in WordPress's own sheet
- Login screen: the rules for the buttons were written `.pollora-login .wp-core-ui …`, but both classes are on `<body>`, so they never matched. The language switcher's button kept wp-admin's blue instead of the theme's primary colour, and the submit button kept WordPress's `button-large` padding
Expand Down
14 changes: 13 additions & 1 deletion src/Hook/UI/Console/ActionMakeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Illuminate\Console\Attributes\Aliases;
use Illuminate\Console\Attributes\Description;
use Symfony\Component\Console\Input\InputOption;

/**
* Class ActionMakeCommand
Expand All @@ -22,12 +23,23 @@ class ActionMakeCommand extends AttributeMakeCommand
*
* @var string
*/
protected $name = 'pollora:make:action {name}';
protected $name = 'pollora:make:action';

/**
* The type of the attribute.
*
* @var string
*/
protected $type = 'Action';

/**
* Get the console command options.
*/
protected function getOptions(): array
{
return [
...parent::getOptions(),
['async', null, InputOption::VALUE_NONE, 'Run the method after the request, with #[Async]'],
];
}
}
29 changes: 25 additions & 4 deletions src/Hook/UI/Console/AttributeMakeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Pollora\Attributes\Action;
use Pollora\Attributes\Async;
use Pollora\Attributes\Filter;
use Pollora\Console\AbstractGeneratorCommand;
use Symfony\Component\Console\Input\InputOption;
Expand Down Expand Up @@ -144,7 +145,7 @@ protected function updateExistingFile(string $path): void
$stub = File::get($stubPath);

$hook = $this->getDefaultOption('hook');
$hookMethodName = 'handle'.Str::studly(preg_replace('/[^a-zA-Z0-9]/', '', (string) $hook));
$hookMethodName = $this->methodName((string) $hook);

$existingContent = File::get($path);

Expand Down Expand Up @@ -173,6 +174,7 @@ protected function ensureAttributeImports(string $content): string
$requiredImports = [
Action::class,
Filter::class,
...($this->isAsync() ? [Async::class] : []),
];

foreach ($requiredImports as $import) {
Expand All @@ -198,21 +200,40 @@ public function makeReplacements(string $stub): string
{
$hook = $this->getDefaultOption('hook');
$priority = $this->getDefaultOption('priority');
$hookMethodName = 'handle'.Str::studly(preg_replace('/[^a-zA-Z0-9]/', '', (string) $hook));
$hookMethodName = $this->methodName((string) $hook);
$hookType = $this->type;

$returnType = $hookType === 'Action' ? ': void' : '';
$arg = $hookType === 'Filter' ? '$arg' : '';
$return = $hookType === 'Filter' ? "\n".' return $arg;' : '';

$async = $this->isAsync();

return str_replace(
['{{ hookType }}', '{{ hook }}', '{{ priority }}', '{{ hookMethodName }}',
'{{ arg }}', '{{ returnType }}', '{{ return }}'],
[$hookType, $hook, ', priority:'.$priority, $hookMethodName, $arg, $returnType, $return],
'{{ arg }}', '{{ returnType }}', '{{ return }}', '{{ async }}', '{{ asyncImport }}'],
[$hookType, $hook, ', priority:'.$priority, $hookMethodName, $arg, $returnType, $return,
$async ? "\n #[Async]" : '', $async ? "\nuse ".Async::class.';' : ''],
$stub
);
}

/**
* The generated method's name: save_post gives handleSavePost.
*/
protected function methodName(string $hook): string
{
return 'handle'.Str::studly((string) preg_replace('/[^a-zA-Z0-9]+/', ' ', $hook));
}

/**
* Whether the generated method carries #[Async]: only an action can be asynchronous.
*/
protected function isAsync(): bool
{
return $this->type === 'Action' && $this->hasOption('async') && $this->option('async');
}

/**
* Get the update stub file path.
*/
Expand Down
2 changes: 1 addition & 1 deletion src/Hook/UI/Console/FilterMakeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class FilterMakeCommand extends AttributeMakeCommand
*
* @var string
*/
protected $name = 'pollora:make:filter {name}';
protected $name = 'pollora:make:filter';

/**
* The type of the attribute.
Expand Down
2 changes: 1 addition & 1 deletion src/Hook/UI/Console/stubs/hook-attribute-update.stub
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#[{{ hookType }}('{{ hook }}'{{ priority }})]
#[{{ hookType }}('{{ hook }}'{{ priority }})]{{ async }}
public function {{ hookMethodName }}({{ arg }}){{ returnType }}
{
// Code is executed on the "{{ hook }}" WordPress {{ hookType }} only.{{ return }}
Expand Down
4 changes: 2 additions & 2 deletions src/Hook/UI/Console/stubs/hook-attribute.stub
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
<?php
namespace {{ namespace }};

use Pollora\Attributes\{{ hookType }};
use Pollora\Attributes\{{ hookType }};{{ asyncImport }}

class {{ class }}
{
#[{{ hookType }}('{{ hook }}'{{ priority }})]
#[{{ hookType }}('{{ hook }}'{{ priority }})]{{ async }}
public function {{ hookMethodName }}({{ arg }}){{ returnType }}
{
// Code is executed on the "{{ hook }}" WordPress {{ hookType }} only.{{ return }}
Expand Down
43 changes: 43 additions & 0 deletions tests/Feature/Hook/ActionMakeCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\File;
use Pollora\Hook\UI\Console\ActionMakeCommand;

beforeEach(function (): void {
$this->themesDir = sys_get_temp_dir().'/pollora-make-action-'.uniqid();
mkdir($this->themesDir.'/test-theme', 0755, true);
config(['theme.path' => $this->themesDir]);
$this->app->make(Kernel::class)->registerCommand($this->app->make(ActionMakeCommand::class));
$this->generated = fn (): string => (string) file_get_contents((string) collect(File::allFiles($this->themesDir))->first()?->getPathname());
});

afterEach(function (): void {
File::deleteDirectory($this->themesDir);
});

it('generates an #[Async] method with --async', function (): void {
$this->artisan('pollora:make:action', ['name' => 'SyncToCrm', '--theme' => 'test-theme', '--hook' => 'save_post', '--async' => true])->assertSuccessful();

expect(($this->generated)())
->toContain("use Pollora\\Attributes\\Action;\nuse Pollora\\Attributes\\Async;\n")
->toContain(" #[Action('save_post', priority:10)]\n #[Async]\n public function handleSavePost(): void");
});

it('generates a synchronous method without --async', function (): void {
$this->artisan('pollora:make:action', ['name' => 'SyncToCrm', '--theme' => 'test-theme', '--hook' => 'save_post'])->assertSuccessful();

expect(($this->generated)())->not->toContain('Async');
});

it('adds an #[Async] method, and its import, to an existing class', function (): void {
$this->artisan('pollora:make:action', ['name' => 'SyncToCrm', '--theme' => 'test-theme', '--hook' => 'save_post'])->assertSuccessful();
$this->artisan('pollora:make:action', ['name' => 'SyncToCrm', '--theme' => 'test-theme', '--hook' => 'user_register', '--async' => true])->assertSuccessful();

expect(($this->generated)())
->toContain('use Pollora\\Attributes\\Async;')
->toContain(" #[Action('save_post', priority:10)]\n public function handleSavePost(): void")
->toContain(" #[Action('user_register', priority:10)]\n #[Async]\n public function handleUserRegister(): void");
});
Loading