├── .git-blame-ignore-refs ├── .github ├── FUNDING.yml └── workflows │ ├── fix-style.yml │ ├── phpstan.yml │ └── run-tests.yml ├── .gitignore ├── .php-cs-fixer.php ├── CHANGELOG.md ├── LICENSE ├── Readme.md ├── composer.json ├── composer.lock ├── config └── arcanist.php ├── database └── migrations │ └── create_wizards_table.php.stub ├── logo.png ├── phpstan-baseline.neon ├── phpstan.neon ├── phpunit.xml ├── src ├── AbstractWizard.php ├── Action │ ├── ActionResult.php │ └── WizardAction.php ├── Arcanist.php ├── ArcanistServiceProvider.php ├── Commands │ ├── CleanupExpiredWizards.php │ ├── WizardMakeCommand.php │ ├── WizardStepMakeCommand.php │ └── stubs │ │ ├── step.stub │ │ └── wizard.stub ├── Contracts │ ├── ResponseRenderer.php │ ├── WizardActionResolver.php │ └── WizardRepository.php ├── Event │ ├── WizardFinished.php │ ├── WizardFinishing.php │ ├── WizardLoaded.php │ └── WizardSaving.php ├── Exception │ ├── CannotUpdateStepException.php │ ├── StepTemplateNotFoundException.php │ ├── UnknownStepException.php │ └── WizardNotFoundException.php ├── Field.php ├── Listener │ └── RemoveCompletedWizardListener.php ├── NullAction.php ├── Renderer │ ├── BladeResponseRenderer.php │ └── FakeResponseRenderer.php ├── Repository │ ├── CacheWizardRepository.php │ ├── DatabaseWizardRepository.php │ ├── FakeWizardRepository.php │ └── Wizard.php ├── Resolver │ └── ContainerWizardActionResolver.php ├── StepResult.php ├── TTL.php ├── Testing │ ├── ResponseRendererContractTests.php │ └── WizardRepositoryContractTests.php └── WizardStep.php └── tests ├── ActionResultTest.php ├── BladeResponseRendererTest.php ├── CacheWizardRepositoryTest.php ├── CleanupExpiredWizardsTest.php ├── ContainerWizardActionResolverTest.php ├── DatabaseWizardRepositoryTest.php ├── FakeResponseRendererTest.php ├── FakeWizardRepositoryTest.php ├── FieldTest.php ├── Fixtures ├── WizardA.php └── WizardB.php ├── InvalidateDependentFieldsTest.php ├── MiddlewareRegistrationTest.php ├── RemoveCompletedWizardListenerTest.php ├── StepResultTest.php ├── TTLTest.php ├── TestCase.php ├── ViewWizardStepTest.php ├── WizardActionTest.php ├── WizardMakeCommandTest.php ├── WizardOmitStepTest.php ├── WizardStepMakeCommandTest.php ├── WizardStepTest.php ├── WizardTest.php ├── WizardTestCase.php ├── __snapshots__ └── files │ ├── WizardMakeCommandTest__testItPrefillsTheGeneratedWizardsSlugProperty__1.php │ ├── WizardMakeCommandTest__testItPrefillsTheGeneratedWizardsTitleProperty__1.php │ ├── WizardMakeCommandTest__testItRegistersAnyProvidedStepsInTheWizard__1.php │ ├── WizardStepMakeCommandTest__testItPrefillsTheStepsSlug__1.php │ └── WizardStepMakeCommandTest__testItPrefillsTheStepsTitle__1.php └── views └── wizards └── blade-wizard └── blade-step.blade.php /.git-blame-ignore-refs: -------------------------------------------------------------------------------- 1 | 8bc60c8ffc39ee8ffd58c42a3602c86c3183acbc 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ksassnowski 2 | -------------------------------------------------------------------------------- /.github/workflows/fix-style.yml: -------------------------------------------------------------------------------- 1 | name: fix-style 2 | 3 | on: [push] 4 | 5 | jobs: 6 | cs-fix: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Get branch names 11 | id: branch-name 12 | uses: tj-actions/branch-names@v5.1 13 | 14 | - name: Checkout code 15 | uses: actions/checkout@v3 16 | with: 17 | ref: ${{ github.head_ref }} 18 | 19 | - name: Setup PHP 20 | uses: shivammathur/setup-php@v2 21 | with: 22 | php-version: 8.1 23 | 24 | - name: Install dependencies 25 | run: composer install 26 | 27 | - name: Fix style 28 | run: composer cs 29 | 30 | - name: Commit style fixes 31 | uses: stefanzweifel/git-auto-commit-action@v4 32 | with: 33 | commit_message: Apply php-cs-fixer changes 34 | -------------------------------------------------------------------------------- /.github/workflows/phpstan.yml: -------------------------------------------------------------------------------- 1 | name: run-phpstan 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | phpstan: 7 | runs-on: ubuntu-latest 8 | name: PHPStan 9 | 10 | steps: 11 | - name: Setup PHP 12 | uses: shivammathur/setup-php@v2 13 | with: 14 | php-version: 8.1 15 | coverage: none 16 | 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | 20 | - name: Cache dependencies 21 | uses: actions/cache@v3 22 | with: 23 | path: ~/.composer/cache/files 24 | key: dependencies-laravel-10.*-php-8.1-composer-${{ hashFiles('composer.json') }} 25 | 26 | - name: Install dependencies 27 | run: composer install 28 | 29 | - name: Run phpstan 30 | run: composer analyze 31 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | php: ["8.1", "8.2"] 11 | laravel: [9.*, 10.*] 12 | stability: [prefer-lowest, prefer-stable] 13 | include: 14 | - laravel: 9.* 15 | testbench: ^7.6 16 | - laravel: 10.* 17 | testbench: ^8.0 18 | os: [ubuntu-latest] 19 | 20 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} 21 | 22 | steps: 23 | - name: Setup PHP 24 | uses: shivammathur/setup-php@v2 25 | with: 26 | php-version: ${{ matrix.php }} 27 | extensions: pdo, sqlite, pdo_sqlite 28 | coverage: none 29 | 30 | - name: Checkout code 31 | uses: actions/checkout@v3 32 | 33 | - name: Cache dependencies 34 | uses: actions/cache@v3 35 | with: 36 | path: ~/.composer/cache/files 37 | key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 38 | 39 | - name: Install dependencies 40 | run: | 41 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 42 | composer update --${{ matrix.stability }} --prefer-dist --no-interaction 43 | 44 | - name: Execute tests 45 | run: vendor/bin/phpunit 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .phpunit.cache/ 3 | .build/ 4 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | false, 18 | 'php_unit_internal_class' => false, 19 | 'error_suppression' => [ 20 | 'noise_remaining_usages' => false, 21 | ], 22 | 'final_class' => false, 23 | 'final_public_method_for_abstract_class' => false, 24 | 'protected_to_private' => false, 25 | 'strict_comparison' => false, 26 | 'static_lambda' => false, 27 | ]); 28 | 29 | $config->getFinder() 30 | ->in([__DIR__ . '/src', __DIR__ . '/tests']) 31 | ->exclude('__snapshots__'); 32 | $config->setCacheFile(__DIR__ . '/.build/php-cs-fixer/.php-cs-fixer.cache'); 33 | 34 | return $config; 35 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.8.0] — 2022-06-23 9 | 10 | ### Added 11 | 12 | - Added ommitable steps (#44, @ercsctt, @ksassnowski) 13 | 14 | ## [0.7.0] — 2022-04-15 15 | 16 | ### Changed 17 | 18 | - Pass `Request` to `transformWizardMethod` (#34, @ziming) 19 | 20 | ## [0.6.0] — 2022-03-16 21 | 22 | ### Added 23 | 24 | - Added support for Laravel 9 (#41, @ziming) 25 | 26 | ## [0.5.2] — 2021-08-20 27 | 28 | ### Changed 29 | 30 | - Changed visibility of `$responseRenderer` from `private` to `protected` in `AbstractWizard` (#21, @erikwittek) 31 | - Changed visibility of `AbstractWizard::fields()` method from `private` to `protected` (#21, @erikwittek) 32 | 33 | ## [0.5.1] — 2021-08-17 34 | 35 | ### Added 36 | 37 | - Added protected `onAfterDelete` method to `AbstractWizard`. This method gets called 38 | after a wizard was deleted instead of a hardcoded redirect (#20, @erikwittek) 39 | 40 | ## [0.5.0] — 2021-08-04 41 | 42 | ### Changed 43 | 44 | - Changed visibility of `fields` method to `public`. This should make it possible to have generic 45 | templates by looping over the field definitions (#17, @thoresuenert) 46 | 47 | ## [0.4.0] — 2021-07-20 48 | 49 | ### Changed 50 | 51 | - **Breaking change:** Changed return type declarations of `ResponseRenderer` interface to be less restrictive 52 | 53 | ## [0.3.1] — 2021-07-15 54 | 55 | ### Changed 56 | 57 | - Use `static` inside of `Field::make()` to make it easier to extend from the `Field` class (#15) 58 | 59 | ## [0.3.0] — 2021-05-21 60 | 61 | ### Added 62 | 63 | - Added `make:wizard` and `make:wizard-step` commands 64 | - Added `transform` method to `Field` class to register arbitrary callbacks for a field. 65 | Note that whatever the callback returns is the value that gets persisted for the field. 66 | 67 | ## [0.2.0] — 2021-05-07 68 | 69 | ### Changed 70 | 71 | - `WizardRepository` implementations no longer throw an exception when trying to delete 72 | a wizard that doesn’t exist. (#1) 73 | 74 | ### Fixed 75 | 76 | - Data from last step is now available in action (#2) 77 | - Fix crashing migration in MySQL 8 (#3). This was due to the fact that MySQL 8 doesn't 78 | support default values on JSON columns. Since a wizard always gets created 79 | with at least an empty array as its data, this can safely be removed. 80 | 81 | ## [0.1.0] — 2021-05-04 82 | 83 | Initial release 84 | 85 | [0.8.0]: https://github.com/laravel-arcanist/arcanist/compare/0.7.0...0.8.0 86 | [0.7.0]: https://github.com/laravel-arcanist/arcanist/compare/0.6.0...0.7.0 87 | [0.6.0]: https://github.com/laravel-arcanist/arcanist/compare/0.5.2...0.6.0 88 | [0.5.2]: https://github.com/laravel-arcanist/arcanist/compare/0.5.1...0.5.2 89 | [0.5.1]: https://github.com/laravel-arcanist/arcanist/compare/0.5.0...0.5.1 90 | [0.5.0]: https://github.com/laravel-arcanist/arcanist/compare/0.4.0...0.5.0 91 | [0.4.0]: https://github.com/laravel-arcanist/arcanist/compare/0.3.1...0.4.0 92 | [0.3.1]: https://github.com/laravel-arcanist/arcanist/compare/0.3.0...0.3.1 93 | [0.3.0]: https://github.com/laravel-arcanist/arcanist/compare/0.2.0...0.3.0 94 | [0.2.0]: https://github.com/laravel-arcanist/arcanist/compare/0.1.0...0.2.0 95 | [0.1.0]: https://github.com/laravel-arcanist/arcanist/releases/tag/0.1.0 96 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright <2021> 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | ![](logo.png) 2 | 3 | ## Installation 4 | 5 | Arcanist requires at least PHP 8.1 and Laravel 9 or Laravel 10. 6 | 7 | ``` 8 | composer require laravel-arcanist/arcanist 9 | ``` 10 | 11 | ## Documentation 12 | 13 | You can find the full documentation [here](https://laravel-arcanist.com). 14 | 15 | ## Credits 16 | 17 | - [Kai Sassnowski](https://github.com/ksassnowski) 18 | - [All contributors](https://github.com/laravel-arcanist/arcanist/contributors) 19 | 20 | ## License 21 | 22 | MIT 23 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-arcanist/arcanist", 3 | "description": "A package to take the pain out of building multi-step form wizards in Laravel.", 4 | "license": "MIT", 5 | "type": "library", 6 | "authors": [ 7 | { 8 | "name": "Kai Sassnowski", 9 | "email": "me@kai-sassnowski.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^8.1", 14 | "facade/ignition-contracts": "^1.0", 15 | "laravel/framework": "^9.0 || ^10.0" 16 | }, 17 | "require-dev": { 18 | "ergebnis/composer-normalize": "^2.15", 19 | "ergebnis/php-cs-fixer-config": "^5.0", 20 | "mockery/mockery": "^1.5.1", 21 | "nunomaduro/collision": "^6.0 || ^7.0", 22 | "nunomaduro/larastan": "^2.4", 23 | "orchestra/testbench": "^7.0 || ^8.0", 24 | "phpstan/phpstan-mockery": "^1.1", 25 | "phpunit/phpunit": "^9.5.8 || ^10.0", 26 | "roave/security-advisories": "dev-latest", 27 | "spatie/phpunit-snapshot-assertions": "^4.2" 28 | }, 29 | "minimum-stability": "dev", 30 | "prefer-stable": true, 31 | "autoload": { 32 | "psr-4": { 33 | "Arcanist\\": "src/" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Arcanist\\Tests\\": "tests/" 39 | } 40 | }, 41 | "config": { 42 | "allow-plugins": { 43 | "ergebnis/composer-normalize": true 44 | } 45 | }, 46 | "extra": { 47 | "laravel": { 48 | "providers": [ 49 | "Arcanist\\ArcanistServiceProvider" 50 | ] 51 | } 52 | }, 53 | "scripts": { 54 | "post-install-cmd": [ 55 | "composer normalize" 56 | ], 57 | "post-update-cmd": [ 58 | "composer normalize" 59 | ], 60 | "analyze": [ 61 | "vendor/bin/phpstan analyze -c phpstan.neon" 62 | ], 63 | "cs": [ 64 | "mkdir -p .build/php-cs-fixer", 65 | "vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php --diff --verbose" 66 | ], 67 | "test": [ 68 | "vendor/bin/phpunit" 69 | ] 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /config/arcanist.php: -------------------------------------------------------------------------------- 1 | '/home', 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Registered Wizards 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you can specify all wizards that should be registered when 26 | | your application starts. Only wizards that are configured 27 | | here will be available. 28 | | 29 | */ 30 | 'wizards' => [ 31 | // \App\Wizards\RegistrationWizard::class, 32 | ], 33 | 34 | /* 35 | |-------------------------------------------------------------------------- 36 | | Wizard Repository 37 | |-------------------------------------------------------------------------- 38 | | 39 | | Here you can configure the wizard repository that gets used 40 | | to save and retrieve a wizard's state between steps. This 41 | | package ships with an Eloquent-based implementation. 42 | | 43 | | The `ttl` setting determines how much time is allowed to pass (in seconds) 44 | | without an update before a wizard is considered `expired` and can 45 | | be deleted. 46 | | 47 | */ 48 | 'storage' => [ 49 | 'driver' => DatabaseWizardRepository::class, 50 | 'ttl' => 24 * 60 * 60, 51 | ], 52 | 53 | /* 54 | |-------------------------------------------------------------------------- 55 | | Wizard Action Resolver 56 | |-------------------------------------------------------------------------- 57 | | 58 | | By default, Arcanist resolves all action out of the Laravel 59 | | service container. If you need a different behavior, you 60 | | can provide a different implementation here. 61 | | 62 | */ 63 | 'action_resolver' => ContainerWizardActionResolver::class, 64 | 65 | /* 66 | |-------------------------------------------------------------------------- 67 | | Response Renderers 68 | |-------------------------------------------------------------------------- 69 | | 70 | | This is where you can configure which response renderer 71 | | Arcanist should use, as well as renderer-specific 72 | | configuration. 73 | | 74 | */ 75 | 'renderers' => [ 76 | 'renderer' => BladeResponseRenderer::class, 77 | 78 | 'blade' => [ 79 | 'view_base_path' => 'wizards', 80 | ], 81 | 82 | 'inertia' => [ 83 | 'component_base_path' => 'Wizards', 84 | ], 85 | ], 86 | 87 | /* 88 | |-------------------------------------------------------------------------- 89 | | Route Prefix 90 | |-------------------------------------------------------------------------- 91 | | 92 | | Here you can change the route prefix that gets added 93 | | to the URLs of each wizard. 94 | | 95 | */ 96 | 'route_prefix' => 'wizard', 97 | 98 | /* 99 | |-------------------------------------------------------------------------- 100 | | Wizard Middleware 101 | |-------------------------------------------------------------------------- 102 | | 103 | | This is where you can default the default middleware group 104 | | that gets applied to all routes of all wizards. You can 105 | | customize it inside each wizard by overwriting the static 106 | | `middleware` method. 107 | | 108 | | Note: Any middleware defined on a wizard gets *merged* with 109 | | this middleware instead of replacing it. 110 | | 111 | */ 112 | 'middleware' => ['web'], 113 | ]; 114 | -------------------------------------------------------------------------------- /database/migrations/create_wizards_table.php.stub: -------------------------------------------------------------------------------- 1 | increments('id'); 13 | $table->string('class'); 14 | $table->json('data'); 15 | $table->timestamps(); 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laravel-arcanist/arcanist/7f437a8cfe9d0906fb831a237a4ee5a3aa2c238b/logo.png -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Method Arcanist\\\\AbstractWizard\\:\\:availableSteps\\(\\) should return array\\ but returns array\\\\.$#" 5 | count: 1 6 | path: src/AbstractWizard.php 7 | 8 | - 9 | message: "#^Method Arcanist\\\\AbstractWizard\\:\\:firstIncompleteStep\\(\\) should return Arcanist\\\\WizardStep but returns Arcanist\\\\WizardStep\\|null\\.$#" 10 | count: 1 11 | path: src/AbstractWizard.php 12 | 13 | - 14 | message: "#^Parameter \\#1 \\$callback of method Illuminate\\\\Support\\\\Collection\\\\:\\:filter\\(\\) expects \\(callable\\(mixed, int\\)\\: bool\\)\\|null, Closure\\(Arcanist\\\\WizardStep\\)\\: bool given\\.$#" 15 | count: 1 16 | path: src/AbstractWizard.php 17 | 18 | - 19 | message: "#^Parameter \\#1 \\$callback of method Illuminate\\\\Support\\\\Collection\\\\:\\:first\\(\\) expects \\(callable\\(mixed, int\\)\\: bool\\)\\|null, Closure\\(Arcanist\\\\WizardStep\\)\\: bool given\\.$#" 20 | count: 1 21 | path: src/AbstractWizard.php 22 | 23 | - 24 | message: "#^Parameter \\#1 \\$callback of method Illuminate\\\\Support\\\\Collection\\\\:\\:map\\(\\) expects callable\\(mixed, int\\)\\: Arcanist\\\\WizardStep, Closure\\(string, mixed\\)\\: Arcanist\\\\WizardStep given\\.$#" 25 | count: 1 26 | path: src/AbstractWizard.php 27 | 28 | - 29 | message: "#^Parameter \\#1 \\$step of method Arcanist\\\\Contracts\\\\ResponseRenderer\\:\\:redirectWithError\\(\\) expects Arcanist\\\\WizardStep, mixed given\\.$#" 30 | count: 1 31 | path: src/AbstractWizard.php 32 | 33 | - 34 | message: "#^Parameter \\#1 \\$wizardId of method Arcanist\\\\AbstractWizard\\:\\:load\\(\\) expects string, int\\|string\\|null given\\.$#" 35 | count: 1 36 | path: src/AbstractWizard.php 37 | 38 | - 39 | message: "#^Parameter \\#1 \\.\\.\\.\\$arrays of function array_merge expects array, mixed given\\.$#" 40 | count: 1 41 | path: src/AbstractWizard.php 42 | 43 | - 44 | message: "#^Parameter \\#3 \\.\\.\\.\\$arrays of function array_merge expects array, mixed given\\.$#" 45 | count: 1 46 | path: src/AbstractWizard.php 47 | 48 | - 49 | message: "#^Property Arcanist\\\\AbstractWizard\\:\\:\\$availableSteps \\(array\\\\|null\\) does not accept array\\\\.$#" 50 | count: 1 51 | path: src/AbstractWizard.php 52 | 53 | - 54 | message: "#^Cannot access offset 'config' on Illuminate\\\\Contracts\\\\Foundation\\\\Application\\.$#" 55 | count: 1 56 | path: src/ArcanistServiceProvider.php 57 | 58 | - 59 | message: "#^Cannot access offset 'events' on Illuminate\\\\Contracts\\\\Foundation\\\\Application\\.$#" 60 | count: 1 61 | path: src/ArcanistServiceProvider.php 62 | 63 | - 64 | message: "#^Parameter \\#1 \\$implementation of method Illuminate\\\\Contracts\\\\Container\\\\ContextualBindingBuilder\\:\\:give\\(\\) expects array\\|Closure\\|string, mixed given\\.$#" 65 | count: 1 66 | path: src/ArcanistServiceProvider.php 67 | 68 | - 69 | message: "#^Parameter \\#1 \\$value of static method Arcanist\\\\TTL\\:\\:fromSeconds\\(\\) expects int, mixed given\\.$#" 70 | count: 1 71 | path: src/ArcanistServiceProvider.php 72 | 73 | - 74 | message: "#^Parameter \\#2 \\$concrete of method Illuminate\\\\Contracts\\\\Container\\\\Container\\:\\:bind\\(\\) expects Closure\\|string\\|null, mixed given\\.$#" 75 | count: 3 76 | path: src/ArcanistServiceProvider.php 77 | 78 | - 79 | message: "#^Parameter \\#1 \\$id of method Arcanist\\\\AbstractWizard\\:\\:setId\\(\\) expects int\\|string\\|null, Ramsey\\\\Uuid\\\\UuidInterface given\\.$#" 80 | count: 1 81 | path: src/Repository/CacheWizardRepository.php 82 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/nunomaduro/larastan/extension.neon 3 | - ./vendor/phpstan/phpstan-mockery/extension.neon 4 | - ./phpstan-baseline.neon 5 | 6 | parameters: 7 | 8 | paths: 9 | - src 10 | 11 | level: 9 12 | 13 | excludePaths: 14 | - tests/__snapshots__/* 15 | 16 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 20 | 21 | src 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/AbstractWizard.php: -------------------------------------------------------------------------------- 1 | 78 | */ 79 | protected array $steps = []; 80 | 81 | /** 82 | * The wizard's id in the database. 83 | * 84 | * @var null|int|string 85 | */ 86 | protected mixed $id = null; 87 | 88 | /** 89 | * The index of the currently active step. 90 | */ 91 | protected int $currentStep = 0; 92 | 93 | /** 94 | * The wizard's stored data. 95 | * 96 | * @var array 97 | */ 98 | protected array $data = []; 99 | 100 | /** 101 | * URL to redirect to after the wizard was deleted. 102 | */ 103 | protected string $redirectTo; 104 | 105 | /** 106 | * @var null|array 107 | */ 108 | private ?array $availableSteps = null; 109 | 110 | public function __construct( 111 | private WizardRepository $wizardRepository, 112 | protected ResponseRenderer $responseRenderer, 113 | private WizardActionResolver $actionResolver, 114 | ) { 115 | /** @var string $redirectTo */ 116 | $redirectTo = config('arcanist.redirect_url', '/home'); 117 | $this->redirectTo = $redirectTo; 118 | 119 | $this->steps = collect($this->steps) 120 | ->map(fn (string $step, $i): WizardStep => app($step)->init($this, $i)) 121 | ->all(); 122 | } 123 | 124 | public static function startUrl(): string 125 | { 126 | $slug = static::$slug; 127 | 128 | return route("wizard.{$slug}.create"); 129 | } 130 | 131 | /** 132 | * Here you can define additional middleware for a wizard 133 | * that gets merged together with the global middleware 134 | * defined in the config. 135 | * 136 | * @return array 137 | */ 138 | public static function middleware(): array 139 | { 140 | return []; 141 | } 142 | 143 | /** 144 | * @return null|int|string 145 | */ 146 | public function getId(): mixed 147 | { 148 | return $this->id; 149 | } 150 | 151 | /** 152 | * @param null|int|string $id 153 | */ 154 | public function setId(mixed $id): void 155 | { 156 | $this->id = $id; 157 | } 158 | 159 | /** 160 | * Data that should be shared with every step in this 161 | * wizard. This data gets merged with a step's view data. 162 | * 163 | * @return array 164 | */ 165 | public function sharedData(Request $request): array 166 | { 167 | return []; 168 | } 169 | 170 | /** 171 | * Renders the template of the first step of this wizard. 172 | */ 173 | public function create(Request $request): Responsable|Response|Renderable 174 | { 175 | return $this->renderStep($request, $this->availableSteps()[0]); 176 | } 177 | 178 | /** 179 | * Renders the template of the current step. 180 | * 181 | * @throws UnknownStepException 182 | */ 183 | public function show(Request $request, string $wizardId, ?string $slug = null): Response|Responsable|Renderable 184 | { 185 | $this->load($wizardId); 186 | 187 | if (null === $slug) { 188 | return $this->responseRenderer->redirect( 189 | $this->firstIncompleteStep(), 190 | $this, 191 | ); 192 | } 193 | 194 | $targetStep = $this->loadStep($slug); 195 | 196 | if (!$this->stepCanBeEdited($targetStep)) { 197 | return $this->responseRenderer->redirect( 198 | $this->firstIncompleteStep(), 199 | $this, 200 | ); 201 | } 202 | 203 | return $this->renderStep($request, $targetStep); 204 | } 205 | 206 | /** 207 | * Handles the form submit for the first step in the workflow. 208 | * 209 | * @throws ValidationException 210 | */ 211 | public function store(Request $request): Response|Responsable|Renderable 212 | { 213 | $step = $this->loadFirstStep(); 214 | 215 | $result = $step->process($request); 216 | 217 | if (!$result->successful()) { 218 | return $this->responseRenderer->redirectWithError( 219 | $this->availableSteps()[0], 220 | $this, 221 | $result->error(), 222 | ); 223 | } 224 | 225 | $this->saveStepData($step, $result->payload()); 226 | 227 | return $this->responseRenderer->redirect( 228 | $this->availableSteps()[1], 229 | $this, 230 | ); 231 | } 232 | 233 | /** 234 | * Handles the form submission of a step in an existing wizard. 235 | * 236 | * @throws CannotUpdateStepException 237 | * @throws UnknownStepException 238 | * @throws ValidationException 239 | */ 240 | public function update(Request $request, string $wizardId, string $slug): Response|Responsable|Renderable 241 | { 242 | $this->load($wizardId); 243 | 244 | $step = $this->loadStep($slug); 245 | 246 | if (!$this->stepCanBeEdited($step)) { 247 | throw new CannotUpdateStepException(); 248 | } 249 | 250 | $result = $step->process($request); 251 | 252 | if (!$result->successful()) { 253 | return $this->responseRenderer->redirectWithError( 254 | $this->steps[0], 255 | $this, 256 | $result->error(), 257 | ); 258 | } 259 | 260 | $this->saveStepData( 261 | $step, 262 | $this->invalidateDependentFields($result->payload()), 263 | ); 264 | 265 | return $this->isLastStep() 266 | ? $this->processLastStep($request, $step) 267 | : $this->responseRenderer->redirect( 268 | $this->nextStep(), 269 | $this, 270 | ); 271 | } 272 | 273 | public function destroy(Request $request, string $wizardId): Response|Responsable|Renderable 274 | { 275 | $this->load($wizardId); 276 | 277 | $this->beforeDelete($request); 278 | 279 | $this->wizardRepository->deleteWizard($this); 280 | 281 | return $this->onAfterDelete(); 282 | } 283 | 284 | /** 285 | * Fetch any previously stored data for this wizard. 286 | */ 287 | public function data(?string $key = null, mixed $default = null): mixed 288 | { 289 | if (null === $key) { 290 | return Arr::except($this->data, '_arcanist'); 291 | } 292 | 293 | return data_get($this->data, $key, $default); 294 | } 295 | 296 | /** 297 | * @param array $data 298 | */ 299 | public function setData(array $data): void 300 | { 301 | $this->data = $data; 302 | } 303 | 304 | /** 305 | * Checks if this wizard already exists or is being created 306 | * for the first time. 307 | */ 308 | public function exists(): bool 309 | { 310 | return null !== $this->id; 311 | } 312 | 313 | /** 314 | * Returns a summary about the current wizard and its steps. 315 | * 316 | * @return array{ 317 | * id: string|int|null, 318 | * slug: string, 319 | * title: string, 320 | * steps: array, 321 | * } 322 | */ 323 | public function summary(): array 324 | { 325 | $current = $this->currentStep(); 326 | 327 | return [ 328 | 'id' => $this->id, 329 | 'slug' => static::$slug, 330 | 'title' => $this->title(), 331 | 'steps' => collect($this->availableSteps())->map(fn (WizardStep $step) => [ 332 | 'slug' => $step->slug, 333 | 'isComplete' => $step->isComplete(), 334 | 'title' => $step->title(), 335 | 'active' => $step->index() === $current->index(), 336 | 'url' => $this->exists() 337 | ? route('wizard.' . static::$slug . '.show', [$this->getId(), $step->slug]) 338 | : null, 339 | ])->all(), 340 | ]; 341 | } 342 | 343 | /** 344 | * Return a structured object of the wizard's data that will be 345 | * passed to the action after the wizard is completed. 346 | */ 347 | protected function transformWizardData(Request $request): mixed 348 | { 349 | return $this->data(); 350 | } 351 | 352 | /** 353 | * Gets called after the last step in the wizard is finished. 354 | */ 355 | protected function onAfterComplete(ActionResult $result): Response|Responsable|Renderable 356 | { 357 | return redirect()->to($this->redirectTo()); 358 | } 359 | 360 | /** 361 | * Hook that gets called before the wizard is deleted. This is 362 | * a good place to free up any resources that might have been 363 | * reserved by the wizard. 364 | */ 365 | protected function beforeDelete(Request $request): void 366 | { 367 | } 368 | 369 | /** 370 | * Gets called after the wizard was deleted. 371 | */ 372 | protected function onAfterDelete(): Response|Responsable|Renderable 373 | { 374 | return redirect()->to($this->redirectTo()); 375 | } 376 | 377 | /** 378 | * The route that gets redirected to after completing the last 379 | * step of the wizard. 380 | */ 381 | protected function redirectTo(): string 382 | { 383 | return $this->redirectTo; 384 | } 385 | 386 | /** 387 | * Returns the wizard's title that gets displayed in the frontend. 388 | */ 389 | protected function title(): string 390 | { 391 | return static::$title; 392 | } 393 | 394 | /** 395 | * @throws UnknownStepException 396 | */ 397 | private function loadStep(string $slug): WizardStep 398 | { 399 | /** @var ?WizardStep $step */ 400 | $step = collect($this->steps) 401 | ->first(fn (WizardStep $step) => $step->slug === $slug); 402 | 403 | if (null === $step) { 404 | throw new UnknownStepException(\sprintf( 405 | 'No step with slug [%s] exists for wizard [%s]', 406 | $slug, 407 | static::class, 408 | )); 409 | } 410 | 411 | $this->currentStep = $step->index(); 412 | 413 | return $step; 414 | } 415 | 416 | private function load(string $wizardId): void 417 | { 418 | $this->id = $wizardId; 419 | 420 | try { 421 | $this->data = $this->wizardRepository->loadData($this); 422 | } catch (WizardNotFoundException $e) { 423 | throw new NotFoundHttpException(previous: $e); 424 | } 425 | 426 | event(new WizardLoaded($this)); 427 | } 428 | 429 | private function renderStep(Request $request, WizardStep $step): Responsable|Response|Renderable 430 | { 431 | return $this->responseRenderer->renderStep( 432 | $step, 433 | $this, 434 | $this->buildViewData($request, $step), 435 | ); 436 | } 437 | 438 | /** 439 | * @return array 440 | */ 441 | private function buildViewData(Request $request, WizardStep $step): array 442 | { 443 | return \array_merge( 444 | $step->viewData($request), 445 | $this->sharedData($request), 446 | ); 447 | } 448 | 449 | /** 450 | * @param array $data 451 | */ 452 | private function saveStepData(WizardStep $step, array $data): void 453 | { 454 | event(new WizardSaving($this)); 455 | 456 | $data['_arcanist'] = \array_merge( 457 | $this->data['_arcanist'] ?? [], 458 | [$step->slug => true], 459 | $data['_arcanist'] ?? [], 460 | ); 461 | 462 | $this->wizardRepository->saveData($this, $data); 463 | } 464 | 465 | private function processLastStep(Request $request, WizardStep $step): Response|Responsable|Renderable 466 | { 467 | $this->load($this->id); 468 | 469 | event(new WizardFinishing($this)); 470 | 471 | $result = $this->actionResolver 472 | ->resolveAction($this->onCompleteAction) 473 | ->execute($this->transformWizardData($request)); 474 | 475 | if (!$result->successful()) { 476 | return $this->responseRenderer->redirectWithError( 477 | $step, 478 | $this, 479 | $result->error(), 480 | ); 481 | } 482 | 483 | $response = $this->onAfterComplete($result); 484 | 485 | event(new WizardFinished($this)); 486 | 487 | return $response; 488 | } 489 | 490 | private function nextStep(): WizardStep 491 | { 492 | return $this->availableSteps()[$this->currentStep + 1]; 493 | } 494 | 495 | private function loadFirstStep(): WizardStep 496 | { 497 | return $this->availableSteps()[0]; 498 | } 499 | 500 | private function currentStep(): WizardStep 501 | { 502 | return $this->availableSteps()[$this->currentStep ?? 0]; 503 | } 504 | 505 | private function isLastStep(): bool 506 | { 507 | return $this->currentStep + 1 === \count($this->availableSteps()); 508 | } 509 | 510 | private function firstIncompleteStep(): WizardStep 511 | { 512 | return collect($this->availableSteps())->first(fn (WizardStep $step) => !$step->isComplete()); 513 | } 514 | 515 | /** 516 | * @return array 517 | */ 518 | private function availableSteps(): array 519 | { 520 | if (null === $this->availableSteps) { 521 | $this->availableSteps = collect($this->steps) 522 | ->filter(fn (WizardStep $step): bool => !$step->omit()) 523 | ->values() 524 | ->all(); 525 | } 526 | 527 | return $this->availableSteps; 528 | } 529 | 530 | /** 531 | * @param array $payload 532 | * 533 | * @return array 534 | */ 535 | private function invalidateDependentFields(array $payload): array 536 | { 537 | $changedFields = collect($payload) 538 | ->filter(fn (mixed $value, string $key) => $this->data($key) !== $value) 539 | ->keys() 540 | ->all(); 541 | 542 | $fields = collect($this->availableSteps()) 543 | ->mapWithKeys(fn (WizardStep $step) => [ 544 | $step->slug => collect($step->dependentFields()) 545 | ->filter(fn (Field $field) => $field->shouldInvalidate($changedFields)), 546 | ]) 547 | ->filter(fn (Collection $fields) => $fields->isNotEmpty()); 548 | 549 | // Mark all steps that had at least one of their fields 550 | // invalidated as incomplete. 551 | $payload = $fields->keys() 552 | ->reduce(function (array $payload, string $stepSlug) { 553 | $payload['_arcanist'][$stepSlug] = null; 554 | 555 | return $payload; 556 | }, $payload); 557 | 558 | // Unset data for all fields that should be invalidated. 559 | return $fields->values() 560 | ->flatten() 561 | ->map->name 562 | ->unique() 563 | ->reduce(function (array $payload, string $fieldName) { 564 | $payload[$fieldName] = null; 565 | 566 | return $payload; 567 | }, $payload); 568 | } 569 | 570 | private function stepCanBeEdited(WizardStep $intendedStep): bool 571 | { 572 | if ($intendedStep->isComplete()) { 573 | return true; 574 | } 575 | 576 | /** @var WizardStep $firstIncompleteStep */ 577 | $firstIncompleteStep = collect($this->availableSteps()) 578 | ->first(fn (WizardStep $step) => !$step->isComplete()); 579 | 580 | return $intendedStep->slug === $firstIncompleteStep->slug; 581 | } 582 | } 583 | -------------------------------------------------------------------------------- /src/Action/ActionResult.php: -------------------------------------------------------------------------------- 1 | $payload 20 | */ 21 | private function __construct( 22 | private bool $successful, 23 | private array $payload, 24 | private ?string $errorMessage = null, 25 | ) { 26 | } 27 | 28 | /** 29 | * @param array $payload 30 | */ 31 | public static function success(array $payload = []): self 32 | { 33 | return new self(true, $payload); 34 | } 35 | 36 | public static function failed(?string $message = null): self 37 | { 38 | return new self(false, [], $message); 39 | } 40 | 41 | public function successful(): bool 42 | { 43 | return $this->successful; 44 | } 45 | 46 | public function get(string $key): mixed 47 | { 48 | return $this->payload[$key]; 49 | } 50 | 51 | public function error(): ?string 52 | { 53 | return $this->errorMessage; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Action/WizardAction.php: -------------------------------------------------------------------------------- 1 | $payload 22 | */ 23 | protected function success(array $payload = []): ActionResult 24 | { 25 | return ActionResult::success($payload); 26 | } 27 | 28 | protected function failure(?string $errorMessage = null): ActionResult 29 | { 30 | return ActionResult::failed($errorMessage); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Arcanist.php: -------------------------------------------------------------------------------- 1 | > $wizards 22 | */ 23 | public static function boot(array $wizards): void 24 | { 25 | /** @var string $routePrefix */ 26 | $routePrefix = config('arcanist.route_prefix'); 27 | 28 | /** @var array $defaultMiddleware */ 29 | $defaultMiddleware = config('arcanist.middleware', []); 30 | 31 | foreach ($wizards as $wizard) { 32 | self::registerRoutes($wizard, $routePrefix, $defaultMiddleware); 33 | } 34 | } 35 | 36 | /** 37 | * @param class-string $wizard 38 | * @param array $defaultMiddleware 39 | */ 40 | private static function registerRoutes(string $wizard, string $routePrefix, array $defaultMiddleware): void 41 | { 42 | $middleware = \array_merge($defaultMiddleware, $wizard::middleware()); 43 | 44 | Route::middleware($middleware) 45 | ->group(function () use ($wizard, $routePrefix): void { 46 | Route::get( 47 | "/{$routePrefix}/{$wizard::$slug}", 48 | "{$wizard}@create", 49 | )->name("wizard.{$wizard::$slug}.create"); 50 | 51 | Route::post( 52 | "/{$routePrefix}/{$wizard::$slug}", 53 | "{$wizard}@store", 54 | )->name("wizard.{$wizard::$slug}.store"); 55 | 56 | Route::get( 57 | "/{$routePrefix}/{$wizard::$slug}/{wizardId}/{slug?}", 58 | "{$wizard}@show", 59 | )->name("wizard.{$wizard::$slug}.show"); 60 | 61 | Route::post( 62 | "/{$routePrefix}/{$wizard::$slug}/{wizardId}/{slug}", 63 | "{$wizard}@update", 64 | )->name("wizard.{$wizard::$slug}.update"); 65 | 66 | Route::delete( 67 | "/{$routePrefix}/{$wizard::$slug}/{wizardId}", 68 | "{$wizard}@destroy", 69 | )->name("wizard.{$wizard::$slug}.delete"); 70 | }); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/ArcanistServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 35 | $this->commands([ 36 | CleanupExpiredWizards::class, 37 | WizardMakeCommand::class, 38 | WizardStepMakeCommand::class, 39 | ]); 40 | } 41 | 42 | $now = Carbon::now(); 43 | $this->publishes([ 44 | __DIR__ . '/../database/migrations/create_wizards_table.php.stub' => database_path('migrations/' . $now->addSecond()->format('Y_m_d_His') . '_' . Str::of('create_wizards_table')->snake()->finish('.php')), 45 | ], 'arcanist-migrations'); 46 | 47 | $this->publishes([ 48 | __DIR__ . '/../config/arcanist.php' => config_path('arcanist.php'), 49 | ], ['config', 'arcanist-config']); 50 | 51 | Arcanist::boot($this->app['config']['arcanist']['wizards']); 52 | } 53 | 54 | public function register(): void 55 | { 56 | $this->mergeConfigFrom(__DIR__ . '/../config/arcanist.php', 'arcanist'); 57 | 58 | $this->app['events']->listen( 59 | WizardFinished::class, 60 | RemoveCompletedWizardListener::class, 61 | ); 62 | 63 | $this->app->bind( 64 | WizardRepository::class, 65 | config('arcanist.storage.driver'), 66 | ); 67 | 68 | $this->app->bind( 69 | ResponseRenderer::class, 70 | config('arcanist.renderers.renderer'), 71 | ); 72 | 73 | $this->app->bind( 74 | WizardActionResolver::class, 75 | config('arcanist.action_resolver'), 76 | ); 77 | 78 | $this->app->when(BladeResponseRenderer::class) 79 | ->needs('$viewBasePath') 80 | ->give(config('arcanist.renderers.blade.view_base_path')); 81 | 82 | $this->app->singleton(TTL::class, fn () => TTL::fromSeconds(config('arcanist.storage.ttl'))); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Commands/CleanupExpiredWizards.php: -------------------------------------------------------------------------------- 1 | ttl->expiresAfter()) 33 | ->delete(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Commands/WizardMakeCommand.php: -------------------------------------------------------------------------------- 1 | getSteps() as $step) { 30 | $this->call('make:wizard-step', [ 31 | 'name' => $step, 32 | 'wizard' => $this->getNameInput(), 33 | ]); 34 | } 35 | 36 | return null; 37 | } 38 | 39 | protected function getStub(): string 40 | { 41 | return __DIR__ . '/stubs/wizard.stub'; 42 | } 43 | 44 | protected function buildClass($name): string 45 | { 46 | $stub = $this->files->get($this->getStub()); 47 | 48 | return $this->replaceNamespace($stub, $name) 49 | ->replaceWizardTitle($stub) 50 | ->replaceWizardSlug($stub) 51 | ->replaceSteps($stub) 52 | ->replaceClass($stub, $name); 53 | } 54 | 55 | protected function getDefaultNamespace($rootNamespace): string 56 | { 57 | return $rootNamespace . '\Wizards\\' . $this->getNameInput(); 58 | } 59 | 60 | /** 61 | * @return array> 62 | */ 63 | protected function getOptions(): array 64 | { 65 | return [ 66 | ['steps', null, InputOption::VALUE_OPTIONAL, 'bla'], 67 | ]; 68 | } 69 | 70 | private function replaceWizardTitle(string &$stub): self 71 | { 72 | $stub = \str_replace('{{ title }}', $this->getNameInput(), $stub); 73 | 74 | return $this; 75 | } 76 | 77 | private function replaceWizardSlug(string &$stub): self 78 | { 79 | $stub = \str_replace('{{ slug }}', Str::kebab($this->getNameInput()), $stub); 80 | 81 | return $this; 82 | } 83 | 84 | private function replaceSteps(string &$stub): self 85 | { 86 | $steps = $this->getSteps(); 87 | 88 | if (empty($steps)) { 89 | $steps = 'protected array $steps = [];'; 90 | } else { 91 | $steps = [ 92 | 'protected array $steps = [', 93 | ...\array_map( 94 | fn (string $step) => ' \App\Wizards\\' . $this->getNameInput() . '\Steps\\' . $step . '::class,', 95 | $steps, 96 | ), 97 | ' ];', 98 | ]; 99 | 100 | $steps = \implode("\n", $steps); 101 | } 102 | 103 | $stub = \str_replace('{{ steps }}', $steps, $stub); 104 | 105 | return $this; 106 | } 107 | 108 | /** 109 | * @return array 110 | */ 111 | private function getSteps(): array 112 | { 113 | /** @var string $steps */ 114 | $steps = $this->option('steps'); 115 | 116 | if (empty($steps)) { 117 | return []; 118 | } 119 | 120 | return \explode(',', $steps); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Commands/WizardStepMakeCommand.php: -------------------------------------------------------------------------------- 1 | argument('wizard'); 37 | 38 | return $rootNamespace . '\Wizards\\' . $wizard . '\Steps'; 39 | } 40 | 41 | protected function buildClass($name): string 42 | { 43 | $stub = $this->files->get($this->getStub()); 44 | 45 | return $this->replaceNamespace($stub, $name) 46 | ->replaceStepTitle($stub) 47 | ->replaceStepSlug($stub) 48 | ->replaceClass($stub, $name); 49 | } 50 | 51 | /** 52 | * @return array> 53 | */ 54 | protected function getArguments(): array 55 | { 56 | return [ 57 | ['name', InputArgument::REQUIRED, 'The name of the class'], 58 | ['wizard', InputArgument::REQUIRED, 'The name of the wizard'], 59 | ]; 60 | } 61 | 62 | private function replaceStepTitle(string &$stub): self 63 | { 64 | $stub = \str_replace('{{ title }}', $this->getNameInput(), $stub); 65 | 66 | return $this; 67 | } 68 | 69 | private function replaceStepSlug(string &$stub): self 70 | { 71 | $stub = \str_replace('{{ slug }}', Str::kebab($this->getNameInput()), $stub); 72 | 73 | return $this; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Commands/stubs/step.stub: -------------------------------------------------------------------------------- 1 | rules(['required', 'unique:users,username']) 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Commands/stubs/wizard.stub: -------------------------------------------------------------------------------- 1 | $data 26 | */ 27 | public function renderStep( 28 | WizardStep $step, 29 | AbstractWizard $wizard, 30 | array $data = [], 31 | ): Response|Responsable|Renderable; 32 | 33 | public function redirect( 34 | WizardStep $step, 35 | AbstractWizard $wizard, 36 | ): Response|Responsable|Renderable; 37 | 38 | public function redirectWithError( 39 | WizardStep $step, 40 | AbstractWizard $wizard, 41 | ?string $error = null, 42 | ): Response|Responsable|Renderable; 43 | } 44 | -------------------------------------------------------------------------------- /src/Contracts/WizardActionResolver.php: -------------------------------------------------------------------------------- 1 | $data 23 | * 24 | * @throws WizardNotFoundException 25 | */ 26 | public function saveData(AbstractWizard $wizard, array $data): void; 27 | 28 | /** 29 | * @throws WizardNotFoundException 30 | * 31 | * @return array 32 | */ 33 | public function loadData(AbstractWizard $wizard): array; 34 | 35 | /** 36 | * @throws WizardNotFoundException 37 | */ 38 | public function deleteWizard(AbstractWizard $wizard): void; 39 | } 40 | -------------------------------------------------------------------------------- /src/Event/WizardFinished.php: -------------------------------------------------------------------------------- 1 | step->slug}]."); 27 | } 28 | 29 | public static function forStep(WizardStep $step): self 30 | { 31 | return new self($step); 32 | } 33 | 34 | public function getSolution(): Solution 35 | { 36 | return BaseSolution::create('No template for wizard step found') 37 | ->setSolutionDescription("No template was found for the step [{$this->step->title}]."); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Exception/UnknownStepException.php: -------------------------------------------------------------------------------- 1 | $rules 27 | * @param array $dependencies 28 | */ 29 | public function __construct( 30 | public string $name, 31 | public array $rules = ['nullable'], 32 | public array $dependencies = [], 33 | ) { 34 | } 35 | 36 | public static function make(string $name): static 37 | { 38 | /** @phpstan-ignore-next-line */ 39 | return new static($name); 40 | } 41 | 42 | /** 43 | * @param array $rules 44 | */ 45 | public function rules(array $rules): self 46 | { 47 | $this->rules = $rules; 48 | 49 | return $this; 50 | } 51 | 52 | public function dependsOn(string ...$fields): self 53 | { 54 | $this->dependencies = $fields; 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * @param array $changedFieldNames 61 | */ 62 | public function shouldInvalidate(array $changedFieldNames): bool 63 | { 64 | return \count(\array_intersect($this->dependencies, $changedFieldNames)) > 0; 65 | } 66 | 67 | public function value(mixed $value): mixed 68 | { 69 | $callback = $this->transformationCallback ?: fn ($val) => $val; 70 | 71 | return $callback($value); 72 | } 73 | 74 | public function transform(callable $callback): self 75 | { 76 | $this->transformationCallback = $callback; 77 | 78 | return $this; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Listener/RemoveCompletedWizardListener.php: -------------------------------------------------------------------------------- 1 | repository->deleteWizard($event->wizard); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/NullAction.php: -------------------------------------------------------------------------------- 1 | success(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Renderer/BladeResponseRenderer.php: -------------------------------------------------------------------------------- 1 | viewBasePath . '.' . $wizard::$slug . '.' . $step->slug; 39 | 40 | try { 41 | return $this->factory->make($viewName, [ 42 | 'wizard' => $wizard->summary(), 43 | 'step' => $data, 44 | ]); 45 | } catch (InvalidArgumentException) { 46 | throw StepTemplateNotFoundException::forStep($step); 47 | } 48 | } 49 | 50 | public function redirect(WizardStep $step, AbstractWizard $wizard): Response|Renderable|Responsable 51 | { 52 | if (!$wizard->exists()) { 53 | return redirect()->route('wizard.' . $wizard::$slug . '.create'); 54 | } 55 | 56 | return redirect()->route('wizard.' . $wizard::$slug . '.show', [ 57 | $wizard->getId(), $step->slug, 58 | ]); 59 | } 60 | 61 | public function redirectWithError( 62 | WizardStep $step, 63 | AbstractWizard $wizard, 64 | ?string $error = null, 65 | ): Response|Renderable|Responsable { 66 | return redirect() 67 | ->route('wizard.' . $wizard::$slug . '.show', [ 68 | $wizard->getId(), 69 | $step->slug, 70 | ]) 71 | ->withErrors(['wizard' => $error]); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Renderer/FakeResponseRenderer.php: -------------------------------------------------------------------------------- 1 | , array> 27 | */ 28 | private array $renderedSteps = []; 29 | private ?string $redirect = null; 30 | private ?string $error = null; 31 | private bool $hasError = false; 32 | 33 | /** 34 | * @param array $data 35 | */ 36 | public function renderStep( 37 | WizardStep $step, 38 | AbstractWizard $wizard, 39 | array $data = [], 40 | ): Response|Responsable { 41 | $this->renderedSteps[$step::class] = $data; 42 | 43 | return new Response(); 44 | } 45 | 46 | public function redirect(WizardStep $step, AbstractWizard $wizard): RedirectResponse 47 | { 48 | $this->redirect = $step::class; 49 | 50 | return new RedirectResponse('::url::'); 51 | } 52 | 53 | public function redirectWithError( 54 | WizardStep $step, 55 | AbstractWizard $wizard, 56 | ?string $error = null, 57 | ): RedirectResponse { 58 | $this->redirect = $step::class; 59 | $this->hasError = true; 60 | $this->error = $error; 61 | 62 | return new RedirectResponse('::url::'); 63 | } 64 | 65 | /** 66 | * @param null|array $data 67 | */ 68 | public function stepWasRendered(string $stepClass, ?array $data = null): bool 69 | { 70 | if (!isset($this->renderedSteps[$stepClass])) { 71 | return false; 72 | } 73 | 74 | if (null !== $data) { 75 | return \array_diff($data, $this->renderedSteps[$stepClass]) === []; 76 | } 77 | 78 | return true; 79 | } 80 | 81 | public function didRedirectTo(string $stepClass): bool 82 | { 83 | return $this->redirect === $stepClass && !$this->hasError; 84 | } 85 | 86 | public function didRedirectWithError(string $stepClass, ?string $message = null): bool 87 | { 88 | return $this->redirect === $stepClass 89 | && $this->hasError 90 | && $this->error === $message; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Repository/CacheWizardRepository.php: -------------------------------------------------------------------------------- 1 | $data 33 | */ 34 | public function saveData(AbstractWizard $wizard, array $data): void 35 | { 36 | if (!$wizard->exists()) { 37 | $wizard->setId(Str::orderedUuid()); 38 | $wizard->setData($data); 39 | 40 | $this->store($wizard, $data); 41 | 42 | return; 43 | } 44 | 45 | $cacheKey = $this->buildCacheKey($wizard); 46 | 47 | if (!Cache::has($cacheKey)) { 48 | throw new WizardNotFoundException(); 49 | } 50 | 51 | $wizard->setData($data); 52 | 53 | /** @var array $storedData */ 54 | $storedData = Cache::get($cacheKey, []); 55 | $this->store($wizard, \array_merge($storedData, $data)); 56 | } 57 | 58 | public function deleteWizard(AbstractWizard $wizard): void 59 | { 60 | $cacheKey = $this->buildCacheKey($wizard); 61 | 62 | if (!Cache::has($cacheKey)) { 63 | return; 64 | } 65 | 66 | Cache::forget($cacheKey); 67 | $wizard->setId(null); 68 | } 69 | 70 | /** 71 | * @return array 72 | */ 73 | public function loadData(AbstractWizard $wizard): array 74 | { 75 | return $this->loadWizard($wizard); 76 | } 77 | 78 | /** 79 | * @return array 80 | */ 81 | private function loadWizard(AbstractWizard $wizard): array 82 | { 83 | $key = $this->keyPrefix . $wizard::class . '.' . $wizard->getId(); 84 | 85 | if (!Cache::has($key)) { 86 | throw new WizardNotFoundException(); 87 | } 88 | 89 | /** @phpstan-ignore-next-line */ 90 | return Cache::get($key); 91 | } 92 | 93 | private function buildCacheKey(AbstractWizard $wizard): string 94 | { 95 | return $this->keyPrefix . $wizard::class . '.' . $wizard->getId(); 96 | } 97 | 98 | /** 99 | * @param array $data 100 | */ 101 | private function store(AbstractWizard $wizard, array $data): void 102 | { 103 | Cache::put( 104 | $this->buildCacheKey($wizard), 105 | $data, 106 | $this->ttl->toSeconds(), 107 | ); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Repository/DatabaseWizardRepository.php: -------------------------------------------------------------------------------- 1 | $data 24 | */ 25 | public function saveData(AbstractWizard $wizard, array $data): void 26 | { 27 | $wizard->exists() 28 | ? $this->updateWizard($wizard, $data) 29 | : $this->createWizard($wizard, $data); 30 | } 31 | 32 | /** 33 | * @return array 34 | */ 35 | public function loadData(AbstractWizard $wizard): array 36 | { 37 | return $this->loadWizard($wizard)->data; 38 | } 39 | 40 | public function deleteWizard(AbstractWizard $wizard): void 41 | { 42 | $affectedRows = Wizard::where([ 43 | 'id' => $wizard->getId(), 44 | 'class' => $wizard::class, 45 | ])->delete(); 46 | 47 | if (0 === $affectedRows) { 48 | return; 49 | } 50 | 51 | $wizard->setId(null); 52 | } 53 | 54 | /** 55 | * @param array $data 56 | */ 57 | private function createWizard(AbstractWizard $wizard, array $data): void 58 | { 59 | $model = Wizard::create([ 60 | 'class' => $wizard::class, 61 | 'data' => $data, 62 | ]); 63 | 64 | $wizard->setId($model->id); 65 | $wizard->setData($data); 66 | } 67 | 68 | /** 69 | * @param array $data 70 | */ 71 | private function updateWizard(AbstractWizard $wizard, array $data): void 72 | { 73 | $model = $this->loadWizard($wizard); 74 | 75 | $model->update([ 76 | 'data' => \array_merge($model->data, $data), 77 | ]); 78 | 79 | $wizard->setData($data); 80 | } 81 | 82 | /** 83 | * @throws WizardNotFoundException 84 | */ 85 | private function loadWizard(AbstractWizard $wizard): Wizard 86 | { 87 | $model = Wizard::where([ 88 | 'id' => $wizard->getId(), 89 | 'class' => $wizard::class, 90 | ])->first(); 91 | 92 | if (null === $model) { 93 | throw new WizardNotFoundException(); 94 | } 95 | 96 | return $model; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Repository/FakeWizardRepository.php: -------------------------------------------------------------------------------- 1 | , array>> $data 26 | */ 27 | public function __construct(private array $data = []) 28 | { 29 | } 30 | 31 | /** 32 | * @param array $data 33 | */ 34 | public function saveData(AbstractWizard $wizard, array $data): void 35 | { 36 | if ($wizard->getId() === null) { 37 | $wizard->setId($this->nextId++); 38 | } 39 | 40 | $this->guardAgainstWizardClassMismatch($wizard); 41 | 42 | $wizardClass = $wizard::class; 43 | 44 | $existingData = $this->data[$wizardClass][$wizard->getId()] ?? []; 45 | 46 | $this->data[$wizardClass][$wizard->getId()] = \array_merge($existingData, $data); 47 | 48 | $wizard->setData($data); 49 | } 50 | 51 | /** 52 | * @return array 53 | */ 54 | public function loadData(AbstractWizard $wizard): array 55 | { 56 | $wizardClass = $wizard::class; 57 | 58 | if (!isset($this->data[$wizardClass][$wizard->getId()])) { 59 | throw new WizardNotFoundException(); 60 | } 61 | 62 | return $this->data[$wizardClass][$wizard->getId()]; 63 | } 64 | 65 | public function deleteWizard(AbstractWizard $wizard): void 66 | { 67 | if ($this->hasIdMismatch($wizard)) { 68 | return; 69 | } 70 | 71 | unset($this->data[$wizard::class][$wizard->getId()]); 72 | 73 | $wizard->setId(null); 74 | } 75 | 76 | private function guardAgainstWizardClassMismatch(AbstractWizard $wizard): void 77 | { 78 | throw_if($this->hasIdMismatch($wizard), new WizardNotFoundException()); 79 | } 80 | 81 | private function hasIdMismatch(AbstractWizard $wizard): bool 82 | { 83 | return collect($this->data) 84 | ->except([$wizard::class]) 85 | ->contains(fn (array $wizards) => isset($wizards[$wizard->getId()])); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Repository/Wizard.php: -------------------------------------------------------------------------------- 1 | |string $class 22 | * @property array $data 23 | * @property int $id 24 | */ 25 | class Wizard extends Model 26 | { 27 | use HasFactory; 28 | protected $guarded = []; 29 | protected $casts = [ 30 | 'data' => 'array', 31 | ]; 32 | } 33 | -------------------------------------------------------------------------------- /src/Resolver/ContainerWizardActionResolver.php: -------------------------------------------------------------------------------- 1 | |string $actionClass 23 | */ 24 | public function resolveAction(string $actionClass): WizardAction 25 | { 26 | return app()->make($actionClass); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/StepResult.php: -------------------------------------------------------------------------------- 1 | $payload 20 | */ 21 | private function __construct( 22 | private bool $successful, 23 | private array $payload = [], 24 | private ?string $error = null, 25 | ) { 26 | } 27 | 28 | /** 29 | * @param array $payload 30 | */ 31 | public static function success(array $payload = []): self 32 | { 33 | return new self(true, payload: $payload); 34 | } 35 | 36 | public static function failed(?string $error = null): self 37 | { 38 | return new self(false, error: $error); 39 | } 40 | 41 | public function successful(): bool 42 | { 43 | return $this->successful; 44 | } 45 | 46 | /** 47 | * @return array 48 | */ 49 | public function payload(): array 50 | { 51 | return $this->payload; 52 | } 53 | 54 | public function error(): ?string 55 | { 56 | return $this->error; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/TTL.php: -------------------------------------------------------------------------------- 1 | $value) { 31 | throw new InvalidArgumentException(); 32 | } 33 | 34 | return new self($value); 35 | } 36 | 37 | public function expiresAfter(): Carbon 38 | { 39 | return now()->sub('seconds', $this->value); 40 | } 41 | 42 | public function toSeconds(): int 43 | { 44 | return $this->value; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Testing/ResponseRendererContractTests.php: -------------------------------------------------------------------------------- 1 | makePartial(); 35 | $wizard->allows('summary')->andReturns(['::summary::']); 36 | $wizard::$slug = 'wizard-slug'; 37 | $step = m::mock(WizardStep::class)->makePartial(); 38 | $step->slug = 'step-with-non-existent-template'; 39 | 40 | $this->expectException(StepTemplateNotFoundException::class); 41 | $this->expectErrorMessage('No template found for step [step-with-non-existent-template].'); 42 | 43 | $this->makeRenderer() 44 | ->renderStep($step, $wizard, []); 45 | } 46 | 47 | /** 48 | * @test 49 | */ 50 | public function it_redirects_to_the_first_step_if_the_wizard_does_not_exist_yet(): void 51 | { 52 | $wizard = m::mock(AbstractWizard::class)->makePartial(); 53 | $wizard::$slug = '::wizard::'; 54 | $wizard->allows('exists')->andReturnFalse(); 55 | $step = m::mock(WizardStep::class); 56 | 57 | Arcanist::boot([$wizard::class]); 58 | 59 | $response = new TestResponse($this->makeRenderer()->redirect($step, $wizard)); 60 | 61 | $response->assertRedirect(route('wizard.::wizard::.create')); 62 | } 63 | 64 | /** 65 | * @test 66 | */ 67 | public function it_redirects_with_an_error(): void 68 | { 69 | $wizard = m::mock(AbstractWizard::class)->makePartial(); 70 | $wizard::$slug = '::wizard::'; 71 | $wizard->setId(1); 72 | $wizard->allows('exists')->andReturnFalse(); 73 | $step = m::mock(WizardStep::class); 74 | 75 | Arcanist::boot([$wizard::class]); 76 | 77 | $response = new TestResponse( 78 | $this->makeRenderer()->redirectWithError($step, $wizard, '::message::'), 79 | ); 80 | 81 | $response->assertSessionHasErrors('wizard'); 82 | } 83 | 84 | abstract protected function makeRenderer(): ResponseRenderer; 85 | } 86 | -------------------------------------------------------------------------------- /src/Testing/WizardRepositoryContractTests.php: -------------------------------------------------------------------------------- 1 | makePartial(); 36 | $repository = $this->createRepository(); 37 | 38 | $repository->saveData($wizard, [ 39 | 'foo' => 'bar', 40 | ]); 41 | 42 | self::assertEquals( 43 | ['foo' => 'bar'], 44 | $repository->loadData($wizard), 45 | ); 46 | } 47 | 48 | /** 49 | * @test 50 | * 51 | * @group WizardRepository 52 | */ 53 | public function it_updates_the_wizards_data_after_saving(): void 54 | { 55 | /** @var AbstractWizard $wizard */ 56 | $wizard = m::mock(AbstractWizard::class)->makePartial(); 57 | $repository = $this->createRepository(); 58 | 59 | $repository->saveData($wizard, ['foo' => 'bar']); 60 | self::assertEquals('bar', $wizard->data('foo')); 61 | 62 | $repository->saveData($wizard, ['foo' => 'baz']); 63 | self::assertEquals('baz', $wizard->data('foo')); 64 | } 65 | 66 | /** 67 | * @test 68 | * 69 | * @group WizardRepository 70 | */ 71 | public function it_throws_an_exception_when_trying_to_load_a_wizard_that_doesnt_exist(): void 72 | { 73 | $repository = $this->createRepository(); 74 | 75 | /** @var AbstractWizard $wizard */ 76 | $wizard = m::mock(AbstractWizard::class)->makePartial(); 77 | $wizard->setId(1); 78 | 79 | $this->expectException(WizardNotFoundException::class); 80 | 81 | $repository->loadData($wizard); 82 | } 83 | 84 | /** 85 | * @test 86 | * 87 | * @group WizardRepository 88 | */ 89 | public function it_creates_a_new_wizard_if_saving_for_the_first_time(): void 90 | { 91 | /** @var AbstractWizard $wizard */ 92 | $wizard = m::mock(AbstractWizard::class)->makePartial(); 93 | $repository = $this->createRepository(); 94 | 95 | $repository->saveData($wizard, []); 96 | 97 | self::assertNotNull($wizard->getId()); 98 | } 99 | 100 | /** 101 | * @test 102 | * 103 | * @group WizardRepository 104 | */ 105 | public function it_updates_an_existing_wizard(): void 106 | { 107 | $repository = $this->createRepository(); 108 | 109 | /** @var AbstractWizard $wizard */ 110 | $wizard = m::mock(AbstractWizard::class)->makePartial(); 111 | 112 | // First, create the wizard we want to update 113 | $repository->saveData($wizard, ['foo' => 'bar']); 114 | $wizardId = $wizard->getId(); 115 | 116 | // Then, update it immediately afterwards 117 | $repository->saveData($wizard, ['foo' => 'baz']); 118 | 119 | self::assertEquals( 120 | $wizardId, 121 | $wizard->getId(), 122 | "Expected wizard id to stay the same but it didn't. `saveData` should not create a new record if the wizard already has an id.", 123 | ); 124 | self::assertEquals( 125 | ['foo' => 'baz'], 126 | $repository->loadData($wizard), 127 | ); 128 | } 129 | 130 | /** 131 | * @test 132 | * 133 | * @group WizardRepository 134 | */ 135 | public function it_keeps_track_of_each_wizard_data_separately(): void 136 | { 137 | $repository = $this->createRepository(); 138 | 139 | /** @var AbstractWizard $wizard1 */ 140 | $wizard1 = m::mock(AbstractWizard::class)->makePartial(); 141 | 142 | /** @var AbstractWizard $wizard2 */ 143 | $wizard2 = m::mock(AbstractWizard::class)->makePartial(); 144 | 145 | $repository->saveData($wizard1, ['foo' => 'bar']); 146 | $repository->saveData($wizard2, ['foo' => 'baz']); 147 | 148 | self::assertEquals(['foo' => 'bar'], $repository->loadData($wizard1)); 149 | self::assertEquals(['foo' => 'baz'], $repository->loadData($wizard2)); 150 | } 151 | 152 | /** 153 | * @test 154 | * 155 | * @group WizardRepository 156 | */ 157 | public function it_merges_new_data_with_the_existing_data(): void 158 | { 159 | $repository = $this->createRepository(); 160 | 161 | /** @var AbstractWizard $wizard */ 162 | $wizard = m::mock(AbstractWizard::class)->makePartial(); 163 | 164 | $repository->saveData($wizard, [ 165 | '::key-1::' => '::old-value-1::', 166 | '::key-2::' => '::old-value-2::', 167 | ]); 168 | 169 | $repository->saveData($wizard, [ 170 | '::key-2::' => '::new-value-2::', 171 | '::key-3::' => '::value-3::', 172 | ]); 173 | 174 | $this->assertEquals([ 175 | '::key-1::' => '::old-value-1::', 176 | '::key-2::' => '::new-value-2::', 177 | '::key-3::' => '::value-3::', 178 | ], $repository->loadData($wizard)); 179 | } 180 | 181 | /** 182 | * @test 183 | * 184 | * @group WizardRepository 185 | */ 186 | public function it_deletes_a_wizard(): void 187 | { 188 | $repository = $this->createRepository(); 189 | 190 | /** @var AbstractWizard $wizard */ 191 | $wizard = m::mock(AbstractWizard::class)->makePartial(); 192 | 193 | // Assuming there is an existing wizard 194 | $repository->saveData($wizard, []); 195 | 196 | // After we deleted it... 197 | $repository->deleteWizard($wizard); 198 | 199 | // Trying to load it again should throw an exception 200 | $this->expectException(WizardNotFoundException::class); 201 | $repository->loadData($wizard); 202 | } 203 | 204 | /** 205 | * @test 206 | * 207 | * @group WizardRepository 208 | */ 209 | public function it_unsets_the_wizard_id_after_deleting_it(): void 210 | { 211 | $repository = $this->createRepository(); 212 | 213 | /** @var AbstractWizard $wizard */ 214 | $wizard = m::mock(AbstractWizard::class)->makePartial(); 215 | 216 | // Assuming there is an existing wizard 217 | $repository->saveData($wizard, []); 218 | 219 | // After we deleted it... 220 | $repository->deleteWizard($wizard); 221 | 222 | // It's id should be `null` 223 | $this->assertNull($wizard->getId()); 224 | } 225 | 226 | /** 227 | * @test 228 | * 229 | * @group WizardRepository 230 | */ 231 | public function it_does_not_unset_the_wizards_it_(): void 232 | { 233 | $repository = $this->createRepository(); 234 | 235 | // Assuming we have previously saved an wizard. 236 | $wizardA = $this->makeWizard(WizardA::class, $repository); 237 | $repository->saveData($wizardA, []); 238 | 239 | // Attempting to load a different type of wizard with the same id 240 | // should result in an exception. 241 | $wizardB = $this->makeWizard(WizardB::class, $repository, $wizardA->getId()); 242 | 243 | $this->expectException(WizardNotFoundException::class); 244 | 245 | $repository->loadData($wizardB); 246 | } 247 | 248 | /** 249 | * @test 250 | * 251 | * @group WizardRepository 252 | */ 253 | public function it_throws_an_exception_when_trying_to_save_a_wizard_but_a_wizard_with_the_same_id_but_different_class_already_exists(): void 254 | { 255 | $repository = $this->createRepository(); 256 | 257 | // Assuming we have previously saved an wizard. 258 | $wizardA = $this->makeWizard(WizardA::class, $repository); 259 | $repository->saveData($wizardA, []); 260 | 261 | // Attempting to save a different type of wizard with the same id 262 | // should result in an exception. 263 | $wizardB = $this->makeWizard(WizardB::class, $repository, $wizardA->getId()); 264 | 265 | $this->expectException(WizardNotFoundException::class); 266 | 267 | $repository->saveData($wizardB, []); 268 | } 269 | 270 | /** 271 | * @test 272 | * 273 | * @group WizardRepository 274 | */ 275 | public function it_does_not_delete_the_wizards_class_and_id_dont_match(): void 276 | { 277 | $repository = $this->createRepository(); 278 | 279 | // Assuming we have previously saved an wizard. 280 | $wizardA = $this->makeWizard(WizardA::class, $repository); 281 | $repository->saveData($wizardA, []); 282 | $expectdId = $wizardA->getId(); 283 | 284 | // Attempting to delete a different type of wizard with the same id 285 | // should not unset $wizardA id. 286 | $wizardB = $this->makeWizard(WizardB::class, $repository, $wizardA->getId()); 287 | 288 | $repository->deleteWizard($wizardB); 289 | 290 | // $wizardB should not have been deleted 291 | $this->assertNotNull($wizardB->getId()); 292 | // $wizardA should not have been deleted 293 | $this->assertEquals($expectdId, $wizardA->getId()); 294 | } 295 | 296 | abstract protected function createRepository(): WizardRepository; 297 | 298 | /** 299 | * @param class-string $class 300 | */ 301 | private function makeWizard(string $class, WizardRepository $repository, mixed $id = null): AbstractWizard 302 | { 303 | $wizard = new $class( 304 | $repository, 305 | m::mock(ResponseRenderer::class), 306 | m::mock(WizardActionResolver::class), 307 | ); 308 | 309 | if (null !== $id) { 310 | $wizard->setId($id); 311 | } 312 | 313 | return $wizard; 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /src/WizardStep.php: -------------------------------------------------------------------------------- 1 | wizard = $wizard; 45 | $this->index = $index; 46 | 47 | return $this; 48 | } 49 | 50 | public function title(): string 51 | { 52 | return $this->title; 53 | } 54 | 55 | /** 56 | * Returns the view data for the template. 57 | * 58 | * @return array 59 | */ 60 | public function viewData(Request $request): array 61 | { 62 | return $this->withFormData(); 63 | } 64 | 65 | public function index(): int 66 | { 67 | return $this->index; 68 | } 69 | 70 | /** 71 | * Checks if this step has already been completed. 72 | */ 73 | public function isComplete(): bool 74 | { 75 | return (bool) $this->data("_arcanist.{$this->slug}", false); 76 | } 77 | 78 | /** 79 | * Checks if the step should be omitted from the wizard. 80 | */ 81 | public function omit(): bool 82 | { 83 | return false; 84 | } 85 | 86 | /** 87 | * @throws ValidationException 88 | */ 89 | public function process(Request $request): StepResult 90 | { 91 | $data = $this->validate($request, $this->rules(), $this->messages(), $this->customAttributes()); 92 | 93 | return collect($this->fields()) 94 | ->mapWithKeys(fn (Field $field) => [ 95 | $field->name => $field->value($data[$field->name] ?? null), 96 | ]) 97 | ->pipe(fn (Collection $values) => $this->handle($request, $values->toArray())); 98 | } 99 | 100 | /** 101 | * @return array 102 | */ 103 | public function dependentFields(): array 104 | { 105 | return collect($this->fields()) 106 | ->filter(fn (Field $field) => \count($field->dependencies) > 0) 107 | ->all(); 108 | } 109 | 110 | /** 111 | * @return array 112 | */ 113 | public function fields(): array 114 | { 115 | return []; 116 | } 117 | 118 | /** 119 | * @param array $payload 120 | */ 121 | protected function handle(Request $request, array $payload): StepResult 122 | { 123 | return $this->success($payload); 124 | } 125 | 126 | /** 127 | * @param array $payload 128 | */ 129 | protected function success(array $payload = []): StepResult 130 | { 131 | return StepResult::success($payload); 132 | } 133 | 134 | protected function error(?string $message = null): StepResult 135 | { 136 | return StepResult::failed($message); 137 | } 138 | 139 | /** 140 | * Checks if this step belongs to an existing wizard, i.e. a wizard 141 | * that has already been saved at least once. 142 | */ 143 | protected function exists(): bool 144 | { 145 | return $this->wizard->exists(); 146 | } 147 | 148 | /** 149 | * Convenience method to include the fields specified in the `rules` 150 | * in the view data. 151 | * 152 | * @param array $additionalData 153 | * 154 | * @return array 155 | */ 156 | protected function withFormData(array $additionalData = []): array 157 | { 158 | return collect($this->rules()) 159 | ->keys() 160 | ->map(fn (string $key) => \explode('.', $key)[0]) 161 | ->mapWithKeys(fn (string $key) => [ 162 | $key => $this->data($key), 163 | ])->merge($additionalData) 164 | ->toArray(); 165 | } 166 | 167 | protected function data(?string $key = null, mixed $default = null): mixed 168 | { 169 | return $this->wizard->data($key, $default); 170 | } 171 | 172 | /** 173 | * The validation rules for submitting the step's form. 174 | * 175 | * @return array> 176 | */ 177 | protected function rules(): array 178 | { 179 | return collect($this->fields()) 180 | ->mapWithKeys(fn (Field $field) => [$field->name => $field->rules]) 181 | ->all(); 182 | } 183 | 184 | /** 185 | * The custom validation messages. 186 | * 187 | * @return array 188 | */ 189 | protected function messages(): array 190 | { 191 | return []; 192 | } 193 | 194 | /** 195 | * The custom attributes label. 196 | * 197 | * @return array 198 | */ 199 | protected function customAttributes(): array 200 | { 201 | return []; 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /tests/ActionResultTest.php: -------------------------------------------------------------------------------- 1 | successful()); 26 | } 27 | 28 | public function testItCanReturnAFailedResult(): void 29 | { 30 | $result = ActionResult::failed(); 31 | 32 | self::assertInstanceOf(ActionResult::class, $result); 33 | self::assertFalse($result->successful()); 34 | } 35 | 36 | public function testItReturnsAPayloadForASuccessfulResult(): void 37 | { 38 | $result = ActionResult::success([ 39 | '::key-1::' => '::value-1::', 40 | '::key-2::' => '::value-2::', 41 | ]); 42 | 43 | self::assertEquals('::value-1::', $result->get('::key-1::')); 44 | self::assertEquals('::value-2::', $result->get('::key-2::')); 45 | } 46 | 47 | public function testItCanPassAlongAnErrorMessageForAFailedResult(): void 48 | { 49 | $result = ActionResult::failed('::message::'); 50 | 51 | self::assertEquals('::message::', $result->error()); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/BladeResponseRendererTest.php: -------------------------------------------------------------------------------- 1 | [__DIR__ . '/views']]); 41 | 42 | $wizard = m::mock(BladeTestWizard::class)->makePartial(); 43 | $wizard->allows('summary') 44 | ->andReturns(['::summary::']); 45 | $this->wizard = $wizard->makePartial(); 46 | $this->step = m::mock(BladeStep::class)->makePartial(); 47 | $this->renderer = app(BladeResponseRenderer::class); 48 | 49 | Arcanist::boot([BladeTestWizard::class]); 50 | } 51 | 52 | public function testItRendersTheCorrectTemplateForAWizardStep(): void 53 | { 54 | /** @var View $response */ 55 | $response = $this->renderer->renderStep( 56 | $this->step, 57 | $this->wizard, 58 | [], 59 | ); 60 | 61 | self::assertInstanceOf(View::class, $response); 62 | self::assertEquals("wizards.{$this->wizard::$slug}.{$this->step->slug}", $response->name()); 63 | } 64 | 65 | public function testItPassesAlongTheViewDataToTheView(): void 66 | { 67 | /** @var View $response */ 68 | $response = $this->renderer->renderStep( 69 | $this->step, 70 | $this->wizard, 71 | ['::key::' => '::value::'], 72 | ); 73 | 74 | self::assertEquals( 75 | ['::key::' => '::value::'], 76 | $response->getData()['step'], 77 | ); 78 | } 79 | 80 | public function testItProvidesTheWizardSummaryToEveryView(): void 81 | { 82 | /** @var View $response */ 83 | $response = $this->renderer->renderStep( 84 | $this->step, 85 | $this->wizard, 86 | [], 87 | ); 88 | 89 | self::assertEquals( 90 | ['::summary::'], 91 | $response->getData()['wizard'], 92 | ); 93 | } 94 | 95 | /** 96 | * @dataProvider redirectToStepProvider 97 | */ 98 | public function testItRedirectsToAStepsView(callable $callRenderer): void 99 | { 100 | $this->wizard->setId(1); 101 | 102 | $response = new TestResponse($callRenderer($this->renderer, $this->wizard, $this->step)); 103 | 104 | $response->assertRedirect(route('wizard.blade-wizard.show', [1, 'blade-step'])); 105 | } 106 | 107 | public function redirectToStepProvider(): Generator 108 | { 109 | yield from [ 110 | 'redirect' => [ 111 | function (BladeResponseRenderer $renderer, AbstractWizard $wizard, WizardStep $step) { 112 | return $renderer->redirect($step, $wizard); 113 | }, 114 | ], 115 | 116 | 'redirectWithErrors' => [ 117 | function (BladeResponseRenderer $renderer, AbstractWizard $wizard, WizardStep $step) { 118 | return $renderer->redirectWithError($step, $wizard); 119 | }, 120 | ], 121 | ]; 122 | } 123 | 124 | protected function makeRenderer(): ResponseRenderer 125 | { 126 | return app(BladeResponseRenderer::class); 127 | } 128 | } 129 | 130 | class BladeTestWizard extends AbstractWizard 131 | { 132 | public static string $slug = 'blade-wizard'; 133 | protected array $steps = [ 134 | BladeStep::class, 135 | ]; 136 | } 137 | 138 | class BladeStep extends WizardStep 139 | { 140 | public string $slug = 'blade-step'; 141 | 142 | public function isComplete(): bool 143 | { 144 | return false; 145 | } 146 | } 147 | 148 | class SomeOtherStep extends WizardStep 149 | { 150 | public function isComplete(): bool 151 | { 152 | return true; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /tests/CacheWizardRepositoryTest.php: -------------------------------------------------------------------------------- 1 | makePartial(); 32 | $repository = new CacheWizardRepository(TTL::fromSeconds(60)); 33 | 34 | $this->travelTo(now()); 35 | $repository->saveData($wizard, ['::key::' => '::value::']); 36 | 37 | $this->travel(61)->seconds(); 38 | $this->expectException(WizardNotFoundException::class); 39 | $repository->loadData($wizard); 40 | } 41 | 42 | public function testItRefreshesTheTtlWhenTheWizardGetsUpdated(): void 43 | { 44 | /** @var AbstractWizard $wizard */ 45 | $wizard = m::mock(AbstractWizard::class)->makePartial(); 46 | $repository = new CacheWizardRepository(TTL::fromSeconds(60)); 47 | 48 | $this->travelTo(now()); 49 | $repository->saveData($wizard, ['::key::' => '::data-1::']); 50 | 51 | $this->travel(59)->seconds(); 52 | $repository->saveData($wizard, ['::key::' => '::data-2::']); 53 | 54 | $this->travel(59)->seconds(); 55 | self::assertEquals(['::key::' => '::data-2::'], $repository->loadData($wizard)); 56 | } 57 | 58 | protected function createRepository(): WizardRepository 59 | { 60 | return new CacheWizardRepository(TTL::fromSeconds(24 * 60 * 60)); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/CleanupExpiredWizardsTest.php: -------------------------------------------------------------------------------- 1 | '[]', 39 | 'class' => $wizardClass::class, 40 | 'updated_at' => now(), 41 | ]); 42 | 43 | Artisan::call('arcanist:clean-expired'); 44 | $this->assertDatabaseHas('wizards', ['id' => $wizard->id]); 45 | 46 | $this->travel(1)->days(); 47 | 48 | Artisan::call('arcanist:clean-expired'); 49 | $this->assertDatabaseMissing('wizards', ['id' => $wizard->id]); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/ContainerWizardActionResolverTest.php: -------------------------------------------------------------------------------- 1 | instance('::action::', $expected); 35 | 36 | $actual = (new ContainerWizardActionResolver())->resolveAction('::action::'); 37 | 38 | self::assertEquals($expected, $actual); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/DatabaseWizardRepositoryTest.php: -------------------------------------------------------------------------------- 1 | renderStep( 30 | new FakeStep(), 31 | $wizard, 32 | ); 33 | 34 | self::assertTrue($renderer->stepWasRendered(FakeStep::class)); 35 | self::assertFalse($renderer->stepWasRendered(AnotherFakeStep::class)); 36 | } 37 | 38 | public function testItRecordsWhatDataAStepWasRenderedWith(): void 39 | { 40 | $renderer = new FakeResponseRenderer(); 41 | $wizard = m::mock(AbstractWizard::class); 42 | 43 | $renderer->renderStep( 44 | new FakeStep(), 45 | $wizard, 46 | ['foo' => 'bar'], 47 | ); 48 | 49 | self::assertTrue($renderer->stepWasRendered(FakeStep::class, ['foo' => 'bar'])); 50 | } 51 | 52 | public function testItRecordsRedirects(): void 53 | { 54 | $wizard = m::mock(AbstractWizard::class); 55 | $step = new FakeStep(); 56 | $renderer = new FakeResponseRenderer(); 57 | 58 | $renderer->redirect($step, $wizard); 59 | 60 | self::assertTrue($renderer->didRedirectTo(FakeStep::class)); 61 | self::assertFalse($renderer->didRedirectTo(AnotherFakeStep::class)); 62 | self::assertFalse($renderer->didRedirectWithError(FakeStep::class)); 63 | } 64 | 65 | public function testItRecordsRedirectsWithErrors(): void 66 | { 67 | $wizard = m::mock(AbstractWizard::class); 68 | $step = new FakeStep(); 69 | $renderer = new FakeResponseRenderer(); 70 | 71 | $renderer->redirectWithError($step, $wizard, '::message::'); 72 | 73 | self::assertTrue($renderer->didRedirectWithError(FakeStep::class, '::message::')); 74 | self::assertFalse($renderer->didRedirectTo(FakeStep::class)); 75 | } 76 | } 77 | 78 | class FakeStep extends WizardStep 79 | { 80 | public string $slug = 'step-slug'; 81 | 82 | public function isComplete(): bool 83 | { 84 | return true; 85 | } 86 | } 87 | 88 | class AnotherFakeStep extends WizardStep 89 | { 90 | public function isComplete(): bool 91 | { 92 | return true; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/FakeWizardRepositoryTest.php: -------------------------------------------------------------------------------- 1 | name); 25 | } 26 | 27 | public function testItCanAddValidationRulesToAField(): void 28 | { 29 | $field = Field::make('::name::') 30 | ->rules(['::something::']); 31 | 32 | self::assertEquals(['::something::'], $field->rules); 33 | } 34 | 35 | public function testItIsNullableByDefault(): void 36 | { 37 | $field = Field::make('::name::'); 38 | 39 | self::assertEquals(['nullable'], $field->rules); 40 | } 41 | 42 | public function testItDoesNotHaveDependenciesByDefault(): void 43 | { 44 | $field = Field::make('::name::'); 45 | 46 | self::assertCount(0, $field->dependencies); 47 | } 48 | 49 | public function testItCanSpecifyDependencies(): void 50 | { 51 | $field = Field::make('::name::') 52 | ->dependsOn('::field-1::', '::field-2::'); 53 | 54 | self::assertEquals([ 55 | '::field-1::', 56 | '::field-2::', 57 | ], $field->dependencies); 58 | } 59 | 60 | public function testItShouldNotChangeIfNoneOfItsDependenciesChanged(): void 61 | { 62 | $field = Field::make('::dependent-field::') 63 | ->dependsOn('::field::'); 64 | 65 | self::assertFalse($field->shouldInvalidate(['::another-field::'])); 66 | } 67 | 68 | public function testItShouldInvalidateIfItsDependencyChanged(): void 69 | { 70 | $field = Field::make('::dependent-field::') 71 | ->dependsOn('::field::'); 72 | 73 | self::assertTrue($field->shouldInvalidate(['::field::'])); 74 | } 75 | 76 | public function testItShouldInvalidateIfAnyOneOfItsDependenciesChanged(): void 77 | { 78 | $field = Field::make('::dependent-field::') 79 | ->dependsOn('::field-1::', '::field-2::'); 80 | 81 | self::assertTrue($field->shouldInvalidate(['::field-2::'])); 82 | } 83 | 84 | public function testItReturnsItsValueIfNoTransformationFunctionIsProvided(): void 85 | { 86 | $field = Field::make('::field::'); 87 | 88 | $actual = $field->value('::value::'); 89 | 90 | self::assertEquals('::value::', $actual); 91 | } 92 | 93 | public function testItAppliesTheRegisteredTransformationCallbackToTheValue(): void 94 | { 95 | $field = Field::make('::field::') 96 | ->transform(function ($value) { 97 | return '::mapped-value::'; 98 | }); 99 | 100 | self::assertEquals('::mapped-value::', $field->value('::value::')); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /tests/Fixtures/WizardA.php: -------------------------------------------------------------------------------- 1 | to('/'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Fixtures/WizardB.php: -------------------------------------------------------------------------------- 1 | to('/'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/InvalidateDependentFieldsTest.php: -------------------------------------------------------------------------------- 1 | createWizardRepository([ 27 | '::normal-field-1::' => '::value-1::', 28 | '::dependent-field-1::' => '::value-2::', 29 | ], DependentStepWizard::class); 30 | $wizard = $this->createWizard(DependentStepWizard::class, repository: $repo); 31 | 32 | $wizard->update(Request::create('::uri::', 'POST', [ 33 | '::normal-field-1::' => '::new-value::', 34 | ]), '1', 'regular-step'); 35 | 36 | self::assertNull($repo->loadData($wizard)['::dependent-field-1::']); 37 | } 38 | 39 | public function testItDoesNotUnsetADependentFieldIfTheFieldItDependsOnWasntChanged(): void 40 | { 41 | $repo = $this->createWizardRepository([ 42 | '::normal-field-1::' => '::value-1::', 43 | '::dependent-field-1::' => '::value-2::', 44 | ], DependentStepWizard::class); 45 | $wizard = $this->createWizard(DependentStepWizard::class, repository: $repo); 46 | 47 | $wizard->update(Request::create('::uri::', 'POST', [ 48 | '::normal-field-1::' => '::value-1::', 49 | ]), '1', 'regular-step'); 50 | 51 | self::assertEquals('::value-2::', $repo->loadData($wizard)['::dependent-field-1::']); 52 | } 53 | 54 | public function testItUnsetsADependentFieldIfOneOfItsDependenciesChanged(): void 55 | { 56 | $repo = $this->createWizardRepository([ 57 | '::normal-field-1::' => '::value-1::', 58 | '::dependent-field-1::' => '::value-2::', 59 | '::normal-field-2::' => '::value-3::', 60 | ], DependentStepWizard::class); 61 | $wizard = $this->createWizard(DependentStepWizard::class, repository: $repo); 62 | 63 | $wizard->update(Request::create('::uri::', 'POST', [ 64 | '::normal-field-1::' => '::value-1::', 65 | '::normal-field-2::' => '::new-value::', 66 | ]), '1', 'regular-step'); 67 | 68 | self::assertNull($repo->loadData($wizard)['::dependent-field-1::']); 69 | } 70 | 71 | public function testItUnsetsAllDependentFieldsIfACommonDependencyChanged(): void 72 | { 73 | $repo = $this->createWizardRepository([ 74 | '::dependent-field-1::' => '::dependent-field-value::', 75 | '::dependent-field-2::' => '::dependent-field-2-value::', 76 | '::normal-field-2::' => '::normal-field-value::', 77 | ], MultiDependentStepWizard::class); 78 | $wizard = $this->createWizard(MultiDependentStepWizard::class, repository: $repo); 79 | 80 | $wizard->update(Request::create('::uri::', 'POST', [ 81 | '::normal-field-2::' => '::new-value::', 82 | ]), '1', 'regular-step'); 83 | 84 | self::assertNull($repo->loadData($wizard)['::dependent-field-1::']); 85 | self::assertNull($repo->loadData($wizard)['::dependent-field-2::']); 86 | } 87 | 88 | public function testItUnsetsAllDependentFieldsWhoseDependencyWasChanged(): void 89 | { 90 | $repo = $this->createWizardRepository([ 91 | '::dependent-field-1::' => '::dependent-field-1-value::', 92 | '::dependent-field-3::' => '::dependent-field-3-value::', 93 | '::normal-field-1::' => '::normal-field-1-value::', 94 | '::normal-field-3::' => '::normal-field-3-value::', 95 | ], MultiDependentStepWizard::class); 96 | $wizard = $this->createWizard(MultiDependentStepWizard::class, repository: $repo); 97 | 98 | $wizard->update(Request::create('::uri::', 'POST', [ 99 | '::normal-field-1::' => '::new-value::', 100 | '::normal-field-3::' => '::new-value::', 101 | ]), '1', 'regular-step'); 102 | 103 | self::assertNull($repo->loadData($wizard)['::dependent-field-1::']); 104 | self::assertNull($repo->loadData($wizard)['::dependent-field-3::']); 105 | } 106 | 107 | public function testItDoesNotInvalidateDependentFieldsIfTheStepWasUnsuccessful(): void 108 | { 109 | $repo = $this->createWizardRepository([ 110 | '::normal-field-1::' => '::normal-field-1-value::', 111 | '::dependent-field-1::' => '::dependent-field-1-value::', 112 | ], FailingStepWizard::class); 113 | $wizard = $this->createWizard(FailingStepWizard::class, repository: $repo); 114 | 115 | $wizard->update(Request::create('::uri::', 'POST', [ 116 | '::normal-field-1::' => '::new-value::', 117 | ]), '1', 'failing-step'); 118 | 119 | self::assertEquals('::dependent-field-1-value::', $repo->loadData($wizard)['::dependent-field-1::']); 120 | } 121 | 122 | public function testItMarksTheStepAsUnfinishedIfAnyOfItsFieldsGotInvalidated(): void 123 | { 124 | $repo = $this->createWizardRepository([ 125 | '::normal-field-1::' => '::value-1::', 126 | '::dependent-field-1::' => '::value-2::', 127 | '_arcanist' => [ 128 | '::step-with-dependent-field-slug::' => true, 129 | ], 130 | ], DependentStepWizard::class); 131 | $wizard = $this->createWizard(DependentStepWizard::class, repository: $repo); 132 | $wizard->setId(1); 133 | 134 | // Sanity check 135 | self::assertTrue( 136 | $repo->loadData($wizard)['_arcanist']['::step-with-dependent-field-slug::'] ?? false, 137 | ); 138 | 139 | $wizard->update(Request::create('::uri::', 'POST', [ 140 | '::normal-field-1::' => '::new-value::', 141 | ]), '1', 'regular-step'); 142 | 143 | self::assertNull( 144 | $repo->loadData($wizard)['_arcanist']['::step-with-dependent-field-slug::'] ?? null, 145 | ); 146 | } 147 | } 148 | 149 | class DependentStepWizard extends AbstractWizard 150 | { 151 | protected array $steps = [ 152 | RegularStep::class, 153 | StepWithDependentField::class, 154 | ]; 155 | } 156 | 157 | class MultiDependentStepWizard extends AbstractWizard 158 | { 159 | protected array $steps = [ 160 | RegularStep::class, 161 | StepWithDependentField::class, 162 | AnotherStepWithDependentField::class, 163 | ]; 164 | } 165 | 166 | class FailingStepWizard extends AbstractWizard 167 | { 168 | protected array $steps = [ 169 | FailingStep::class, 170 | StepWithDependentField::class, 171 | ]; 172 | } 173 | 174 | class RegularStep extends WizardStep 175 | { 176 | public string $slug = 'regular-step'; 177 | 178 | public function isComplete(): bool 179 | { 180 | return true; 181 | } 182 | 183 | public function fields(): array 184 | { 185 | return [ 186 | Field::make('::normal-field-1::'), 187 | Field::make('::normal-field-2::'), 188 | Field::make('::normal-field-3::'), 189 | ]; 190 | } 191 | } 192 | 193 | class FailingStep extends WizardStep 194 | { 195 | public string $slug = 'failing-step'; 196 | 197 | public function isComplete(): bool 198 | { 199 | return false; 200 | } 201 | 202 | public function fields(): array 203 | { 204 | return [ 205 | Field::make('::normal-field-1::'), 206 | ]; 207 | } 208 | 209 | protected function handle(Request $request, array $payload): StepResult 210 | { 211 | return $this->error('Whoops'); 212 | } 213 | } 214 | 215 | class StepWithDependentField extends WizardStep 216 | { 217 | public string $slug = '::step-with-dependent-field-slug::'; 218 | 219 | public function isComplete(): bool 220 | { 221 | return $this->data('::dependent-field-1::') !== null; 222 | } 223 | 224 | public function fields(): array 225 | { 226 | return [ 227 | Field::make('::dependent-field-1::') 228 | ->dependsOn('::normal-field-1::', '::normal-field-2::'), 229 | ]; 230 | } 231 | } 232 | 233 | class AnotherStepWithDependentField extends WizardStep 234 | { 235 | public function isComplete(): bool 236 | { 237 | return $this->data('::dependent-field-2::') !== null; 238 | } 239 | 240 | public function fields(): array 241 | { 242 | return [ 243 | Field::make('::dependent-field-2::') 244 | ->dependsOn('::normal-field-2::'), 245 | 246 | Field::make('::dependent-field-3::') 247 | ->dependsOn('::normal-field-3::'), 248 | ]; 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /tests/MiddlewareRegistrationTest.php: -------------------------------------------------------------------------------- 1 | ['web']]); 24 | 25 | Arcanist::boot([NoMiddlewareWizard::class]); 26 | 27 | $this->assertRouteUsesMiddleware('wizard.no-middleware.create', ['web'], true); 28 | } 29 | 30 | public function testItMergesMiddlewareDefinedOnTheWizardWithMiddlewareFromConfig(): void 31 | { 32 | config(['arcanist.middleware' => ['web']]); 33 | 34 | Arcanist::boot([ExtraMiddlewareWizard::class]); 35 | 36 | $this->assertRouteUsesMiddleware('wizard.extra-middleware.create', ['web', 'auth'], true); 37 | } 38 | } 39 | 40 | class NoMiddlewareWizard extends AbstractWizard 41 | { 42 | public static string $slug = 'no-middleware'; 43 | } 44 | 45 | class ExtraMiddlewareWizard extends AbstractWizard 46 | { 47 | public static string $slug = 'extra-middleware'; 48 | 49 | public static function middleware(): array 50 | { 51 | return ['auth']; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/RemoveCompletedWizardListenerTest.php: -------------------------------------------------------------------------------- 1 | makePartial(); 28 | $wizard->setId(1); 29 | $repository = new FakeWizardRepository(); 30 | $event = new WizardFinished($wizard); 31 | $listener = new RemoveCompletedWizardListener($repository); 32 | 33 | $listener->handle($event); 34 | 35 | self::assertFalse($wizard->exists()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/StepResultTest.php: -------------------------------------------------------------------------------- 1 | successful()); 26 | } 27 | 28 | public function testCanCreateAFailedResult(): void 29 | { 30 | $result = StepResult::failed(); 31 | 32 | self::assertFalse($result->successful()); 33 | } 34 | 35 | public function testCanPassAlongDataToSuccessfulResult(): void 36 | { 37 | $result = StepResult::success(['::key::' => '::value::']); 38 | 39 | self::assertEquals(['::key::' => '::value::'], $result->payload()); 40 | } 41 | 42 | public function testCanPassAlongErrorMessageToFailedResult(): void 43 | { 44 | $result = StepResult::failed('::message::'); 45 | 46 | self::assertEquals('::message::', $result->error()); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/TTLTest.php: -------------------------------------------------------------------------------- 1 | expiresAfter()->eq($expectedDate())); 33 | } 34 | 35 | public function validValueProvider(): Generator 36 | { 37 | yield from [ 38 | [0, fn () => now()], 39 | [24 * 60 * 60, fn () => now()->subDay()], 40 | [60, fn () => now()->subMinute()], 41 | ]; 42 | } 43 | 44 | public function testItCannotBeNegative(): void 45 | { 46 | $this->expectException(InvalidArgumentException::class); 47 | 48 | TTL::fromSeconds(-1); 49 | } 50 | 51 | /** 52 | * @dataProvider secondsProvider 53 | */ 54 | public function testItCanBeTurnedBackToSeconds(int $value): void 55 | { 56 | Carbon::setTestNow(now()); 57 | 58 | $ttl = TTL::fromSeconds($value); 59 | 60 | self::assertEquals($value, $ttl->toSeconds()); 61 | } 62 | 63 | public function secondsProvider(): Generator 64 | { 65 | yield from [ 66 | [0], 67 | [60], 68 | [24 * 60 * 60], 69 | ]; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | $middlewares 25 | */ 26 | public function assertRouteUsesMiddleware(string $routeName, array $middlewares, bool $exact = false): void 27 | { 28 | $router = resolve(Router::class); 29 | 30 | $router->getRoutes() 31 | ->refreshNameLookups(); 32 | 33 | $route = $router->getRoutes()->getByName($routeName); 34 | 35 | if (null === $route) { 36 | PHPUnitAssert::fail("Unable to find route for name `{$routeName}`"); 37 | } 38 | 39 | $usedMiddlewares = $route->gatherMiddleware(); 40 | 41 | if ($exact) { 42 | $unusedMiddlewares = \array_diff($middlewares, $usedMiddlewares); 43 | $extraMiddlewares = \array_diff($usedMiddlewares, $middlewares); 44 | 45 | $messages = []; 46 | 47 | if ($extraMiddlewares) { 48 | $messages[] = 'uses unexpected `' . \implode(', ', $extraMiddlewares) . '` middlware(s)'; 49 | } 50 | 51 | if ($unusedMiddlewares) { 52 | $messages[] = "doesn't use expected `" . \implode(', ', $unusedMiddlewares) . '` middlware(s)'; 53 | } 54 | 55 | $messages = \implode(' and ', $messages); 56 | 57 | PHPUnitAssert::assertSame(\count($unusedMiddlewares) + \count($extraMiddlewares), 0, "Route `{$routeName}` " . $messages); 58 | } else { 59 | $unusedMiddlewares = \array_diff($middlewares, $usedMiddlewares); 60 | 61 | PHPUnitAssert::assertSame(\count($unusedMiddlewares), 0, "Route `{$routeName}` does not use expected `" . \implode(', ', $unusedMiddlewares) . '` middleware(s)'); 62 | } 63 | } 64 | 65 | /** 66 | * @param mixed $app 67 | * 68 | * @return array> 69 | */ 70 | protected function getPackageProviders($app): array 71 | { 72 | return [ 73 | ArcanistServiceProvider::class, 74 | ]; 75 | } 76 | 77 | protected function getEnvironmentSetUp($app): void 78 | { 79 | config()->set('database.default', 'sqlite'); 80 | config()->set('database.connections.sqlite', [ 81 | 'driver' => 'sqlite', 82 | 'database' => ':memory:', 83 | 'prefix' => '', 84 | ]); 85 | 86 | include_once __DIR__ . '/../database/migrations/create_wizards_table.php.stub'; 87 | 88 | /** @phpstan-ignore-next-line */ 89 | (new \CreateWizardsTable())->up(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /tests/ViewWizardStepTest.php: -------------------------------------------------------------------------------- 1 | createWizard(IncompleteStepWizard::class, renderer: $renderer); 28 | 29 | $wizard->show(new Request(), '1', 'incomplete-step-2'); 30 | 31 | self::assertTrue($renderer->didRedirectTo(IncompleteStep::class)); 32 | } 33 | 34 | public function testItAllowsSkippingAheadIfTheTargetStepHasBeenCompletedPreviously(): void 35 | { 36 | $renderer = new FakeResponseRenderer(); 37 | $wizard = $this->createWizard(IncompleteStepWizard::class, renderer: $renderer); 38 | 39 | $wizard->show(new Request(), '1', 'complete-step-2'); 40 | 41 | self::assertTrue($renderer->stepWasRendered(AnotherCompleteStep::class)); 42 | } 43 | 44 | public function testItDoesNotAllowUpdatingAnIncompleteStepIfThePreviousStepsHaveNotBeenCompletedYet(): void 45 | { 46 | $renderer = new FakeResponseRenderer(); 47 | $wizard = $this->createWizard(IncompleteStepWizard::class, renderer: $renderer); 48 | 49 | $this->expectException(CannotUpdateStepException::class); 50 | 51 | $wizard->update(new Request(), '1', 'incomplete-step-2'); 52 | } 53 | } 54 | 55 | class IncompleteStepWizard extends AbstractWizard 56 | { 57 | protected array $steps = [ 58 | CompleteStep::class, 59 | IncompleteStep::class, 60 | AnotherCompleteStep::class, 61 | AnotherIncompleteStep::class, 62 | ]; 63 | } 64 | 65 | class CompleteStep extends WizardStep 66 | { 67 | public string $slug = 'complete-step-1'; 68 | 69 | public function isComplete(): bool 70 | { 71 | return true; 72 | } 73 | } 74 | 75 | class IncompleteStep extends WizardStep 76 | { 77 | public string $slug = 'incomplete-step-1'; 78 | 79 | public function isComplete(): bool 80 | { 81 | return false; 82 | } 83 | } 84 | 85 | class AnotherCompleteStep extends WizardStep 86 | { 87 | public string $slug = 'complete-step-2'; 88 | 89 | public function isComplete(): bool 90 | { 91 | return true; 92 | } 93 | } 94 | 95 | class AnotherIncompleteStep extends WizardStep 96 | { 97 | public string $slug = 'incomplete-step-2'; 98 | 99 | public function isComplete(): bool 100 | { 101 | return false; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/WizardActionTest.php: -------------------------------------------------------------------------------- 1 | success(); 27 | } 28 | }; 29 | 30 | $result = $action->execute(); 31 | 32 | self::assertTrue($result->successful()); 33 | } 34 | 35 | public function testItPassesAlongThePayload(): void 36 | { 37 | $action = new class() extends WizardAction { 38 | public function execute(mixed $payload = null): ActionResult 39 | { 40 | return $this->success(['::key::' => '::value::']); 41 | } 42 | }; 43 | 44 | $result = $action->execute(); 45 | 46 | self::assertEquals('::value::', $result->get('::key::')); 47 | } 48 | 49 | public function testItCanReturnAFailedResult(): void 50 | { 51 | $action = new class() extends WizardAction { 52 | public function execute(mixed $payload = null): ActionResult 53 | { 54 | return $this->failure(); 55 | } 56 | }; 57 | 58 | $result = $action->execute(); 59 | 60 | self::assertFalse($result->successful()); 61 | } 62 | 63 | public function testItPassesAlongTheErrorMessageForAFailedResult(): void 64 | { 65 | $action = new class() extends WizardAction { 66 | public function execute(mixed $payload = null): ActionResult 67 | { 68 | return $this->failure('::error::'); 69 | } 70 | }; 71 | 72 | $result = $action->execute(); 73 | 74 | self::assertEquals('::error::', $result->error()); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/WizardMakeCommandTest.php: -------------------------------------------------------------------------------- 1 | wizardName = Str::random(); 32 | } 33 | 34 | protected function tearDown(): void 35 | { 36 | File::deleteDirectory(app_path('Wizards')); 37 | 38 | parent::tearDown(); 39 | } 40 | 41 | /** 42 | * @dataProvider stepCommandInvocationProvider 43 | * 44 | * @param array $steps 45 | */ 46 | public function testItCreatesStepsIfTheStepOptionWasProvided(array $steps): void 47 | { 48 | $option = \implode(',', $steps); 49 | 50 | $this 51 | ->withoutExceptionHandling() 52 | ->artisan('make:wizard ' . $this->wizardName . ' --steps=' . $option); 53 | 54 | foreach ($steps as $step) { 55 | self::assertTrue(File::exists( 56 | app_path('Wizards/' . $this->wizardName . '/Steps/' . $step . '.php'), 57 | )); 58 | } 59 | } 60 | 61 | public function stepCommandInvocationProvider(): Generator 62 | { 63 | yield from [ 64 | [['Step1']], 65 | [['Step1', 'Step2']], 66 | ]; 67 | } 68 | 69 | public function testItDoesNotCreateStepsIfTheOptionIsntProvided(): void 70 | { 71 | $this->artisan('make:wizard ' . $this->wizardName); 72 | 73 | self::assertFalse(File::exists(app_path('Wizards/' . $this->wizardName . '/Steps'))); 74 | } 75 | 76 | /** 77 | * @dataProvider emptyStepNameProvider 78 | */ 79 | public function testItDoesNotCreateStepsIfNoStepNamesAreProvided(string $option): void 80 | { 81 | $this->artisan('make:wizard ' . $this->wizardName . ' ' . $option); 82 | 83 | self::assertFalse(File::exists(app_path('Wizards/' . $this->wizardName . '/Steps'))); 84 | } 85 | 86 | public function emptyStepNameProvider(): Generator 87 | { 88 | yield from [ 89 | ['--steps'], 90 | ['--steps='], 91 | ]; 92 | } 93 | 94 | public function testItPrefillsTheGeneratedWizardsTitleProperty(): void 95 | { 96 | $this->artisan('make:wizard TestWizard'); 97 | 98 | $this->assertMatchesFileSnapshot(app_path('Wizards/TestWizard/TestWizard.php')); 99 | } 100 | 101 | public function testItPrefillsTheGeneratedWizardsSlugProperty(): void 102 | { 103 | $this->artisan('make:wizard TestWizard'); 104 | 105 | $this->assertMatchesFileSnapshot(app_path('Wizards/TestWizard/TestWizard.php')); 106 | } 107 | 108 | public function testItRegistersAnyProvidedStepsInTheWizard(): void 109 | { 110 | $this->artisan('make:wizard TestWizard --steps=Step1,Step2'); 111 | 112 | $this->assertMatchesFileSnapshot(app_path('Wizards/TestWizard/TestWizard.php')); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /tests/WizardOmitStepTest.php: -------------------------------------------------------------------------------- 1 | createWizard(MiddleOmittedStepWizard::class, renderer: $renderer); 59 | 60 | $wizard->update(Request::create('::uri::', 'POST', [ 61 | 'first_name' => '::first-name::', 62 | 'last_name' => '::last-name::', 63 | 'has_job' => true, 64 | ]), '1', 'step-name'); 65 | 66 | self::assertTrue($renderer->didRedirectTo(AnotherStep::class)); 67 | } 68 | 69 | public function testFirstOmittedStepGetsSkipped(): void 70 | { 71 | $renderer = new FakeResponseRenderer(); 72 | $wizard = $this->createWizard(FirstOmittedStepWizard::class, renderer: $renderer); 73 | 74 | $wizard->create(new Request()); 75 | 76 | self::assertTrue($renderer->stepWasRendered(FirstStep::class)); 77 | } 78 | 79 | public function testLastOmittedStepSubmits(): void 80 | { 81 | // If the step is the last in the wizard, make sure it submits even if omitted. 82 | $actionSpy = m::spy(WizardAction::class); 83 | $actionSpy->allows('execute')->andReturns(ActionResult::success()); 84 | $actionResolver = m::mock(WizardActionResolver::class); 85 | $actionResolver 86 | ->allows('resolveAction') 87 | ->with(NullAction::class) 88 | ->andReturn($actionSpy); 89 | $wizard = $this->createWizard(MultiStepOmitWizard::class, resolver: $actionResolver); 90 | 91 | $wizard->update(Request::create('::uri::', 'POST', [ 92 | 'first_name' => '::first-name::', 93 | 'last_name' => '::last-name::', 94 | 'has_job' => true, 95 | ]), '1', 'step-name'); 96 | 97 | $actionSpy->shouldHaveReceived('execute') 98 | ->once(); 99 | } 100 | 101 | public function testTwoOmittedStepsSkipToNextAvailable(): void 102 | { 103 | // If there's multiple steps in a row that are omitted, make sure the wizard directs to the next available step 104 | $renderer = new FakeResponseRenderer(); 105 | $wizard = $this->createWizard(MultipleOmittedStepWizard::class, renderer: $renderer); 106 | 107 | $wizard->update(Request::create('::uri::', 'POST', [ 108 | 'first_name' => '::first-name::', 109 | 'last_name' => '::last-name::', 110 | 'has_job' => true, 111 | ]), '1', 'step-name'); 112 | 113 | self::assertTrue($renderer->didRedirectTo(AnotherStep::class)); 114 | } 115 | 116 | public function testOnlyComputesAvailableStepsOnce(): void 117 | { 118 | $wizard = $this->createWizard( 119 | MiddleOmittedStepWizard::class, 120 | renderer: new FakeResponseRenderer(), 121 | ); 122 | 123 | $wizard->summary(); 124 | $wizard->summary(); 125 | 126 | self::assertSame(1, OptionalStep::$omitCalled); 127 | } 128 | } 129 | 130 | class MultiStepOmitWizard extends AbstractWizard 131 | { 132 | protected array $steps = [ 133 | FirstStep::class, 134 | OptionalStep::class, 135 | ]; 136 | } 137 | 138 | class MiddleOmittedStepWizard extends AbstractWizard 139 | { 140 | protected array $steps = [ 141 | FirstStep::class, 142 | OptionalStep::class, 143 | AnotherStep::class, 144 | ]; 145 | } 146 | 147 | class FirstOmittedStepWizard extends AbstractWizard 148 | { 149 | protected array $steps = [ 150 | OptionalStep::class, 151 | FirstStep::class, 152 | ]; 153 | } 154 | 155 | class LastOmittedStepWizard extends AbstractWizard 156 | { 157 | protected array $steps = [ 158 | FirstStep::class, 159 | AnotherStep::class, 160 | OptionalStep::class, 161 | ]; 162 | } 163 | 164 | class MultipleOmittedStepWizard extends AbstractWizard 165 | { 166 | protected array $steps = [ 167 | FirstStep::class, 168 | OptionalStep::class, 169 | AnotherOptionalStep::class, 170 | AnotherStep::class, 171 | ]; 172 | } 173 | 174 | class FirstStep extends WizardStep 175 | { 176 | public string $title = '::step-1-name::'; 177 | public string $slug = 'step-name'; 178 | 179 | public function fields(): array 180 | { 181 | return [ 182 | Field::make('first_name') 183 | ->rules(['required']), 184 | 185 | Field::make('last_name') 186 | ->rules(['required']), 187 | 188 | Field::make('has_job') 189 | ->rules(['required', 'bool']), 190 | ]; 191 | } 192 | 193 | public function viewData(Request $request): array 194 | { 195 | return [ 196 | 'first_name' => $this->data('first_name'), 197 | 'last_name' => $this->data('last_name'), 198 | 'has_job' => $this->data('has_job'), 199 | ]; 200 | } 201 | 202 | public function isComplete(): bool 203 | { 204 | return true; 205 | } 206 | } 207 | 208 | class OptionalStep extends WizardStep 209 | { 210 | public static int $omitCalled = 0; 211 | public string $title = '::step-2-name::'; 212 | public string $slug = 'step-2-name'; 213 | 214 | public function fields(): array 215 | { 216 | return [ 217 | Field::make('company') 218 | ->rules(['required']), 219 | ]; 220 | } 221 | 222 | public function viewData(Request $request): array 223 | { 224 | return [ 225 | 'company' => $this->data('company'), 226 | ]; 227 | } 228 | 229 | public function omit(): bool 230 | { 231 | ++static::$omitCalled; 232 | 233 | return true; 234 | } 235 | } 236 | 237 | class AnotherOptionalStep extends WizardStep 238 | { 239 | public string $title = '::step-4-name::'; 240 | public string $slug = 'step-4-name'; 241 | 242 | public function fields(): array 243 | { 244 | return [ 245 | Field::make('xbox_gamertag') 246 | ->rules(['required']), 247 | ]; 248 | } 249 | 250 | public function viewData(Request $request): array 251 | { 252 | return [ 253 | 'xbox_gamertag' => $this->data('xbox_gamertag'), 254 | ]; 255 | } 256 | 257 | public function omit(): bool 258 | { 259 | return true; 260 | } 261 | } 262 | 263 | class AnotherStep extends WizardStep 264 | { 265 | public string $title = '::step-3-name::'; 266 | public string $slug = 'step-3-name'; 267 | 268 | public function fields(): array 269 | { 270 | return [ 271 | Field::make('pet_name') 272 | ->rules(['required']), 273 | ]; 274 | } 275 | 276 | public function viewData(Request $request): array 277 | { 278 | return [ 279 | 'pet_name' => $this->data('pet_name'), 280 | ]; 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /tests/WizardStepMakeCommandTest.php: -------------------------------------------------------------------------------- 1 | artisan('make:wizard-step Step1 TestWizard'); 25 | 26 | $this->assertMatchesFileSnapshot(app_path('Wizards/TestWizard/Steps/Step1.php')); 27 | } 28 | 29 | public function testItPrefillsTheStepsSlug(): void 30 | { 31 | $this->artisan('make:wizard-step Step1 TestWizard'); 32 | 33 | $this->assertMatchesFileSnapshot(app_path('Wizards/TestWizard/Steps/Step1.php')); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/WizardStepTest.php: -------------------------------------------------------------------------------- 1 | step = new class() extends WizardStep { 29 | public string $slug = '::step-slug::'; 30 | }; 31 | } 32 | 33 | public function testItConsidersAStepFinishedIfItWasSuccessfullySubmittedBefore(): void 34 | { 35 | $wizard = m::mock(AbstractWizard::class); 36 | $wizard->allows('data')->with('_arcanist.::step-slug::', false)->andReturnTrue(); 37 | $this->step->init($wizard, 1); 38 | 39 | self::assertTrue($this->step->isComplete()); 40 | } 41 | 42 | public function testItConsidersAStepUnfinishedIfItWasNeverSuccessfullySubmittedBefore(): void 43 | { 44 | $wizard = m::mock(AbstractWizard::class); 45 | $wizard->allows('data')->with('_arcanist.::step-slug::', false)->andReturnNull(); 46 | $this->step->init($wizard, 1); 47 | 48 | self::assertFalse($this->step->isComplete()); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/WizardTest.php: -------------------------------------------------------------------------------- 1 | createWizard( 69 | TestWizard::class, 70 | repository: $this->createWizardRepository(), 71 | renderer: $renderer, 72 | ); 73 | 74 | $wizard->create(new Request()); 75 | 76 | self::assertTrue($renderer->stepWasRendered(TestStep::class)); 77 | } 78 | 79 | public function testItThrowsAnExceptionIfNoStepExistsForTheProvidedSlug(): void 80 | { 81 | $this->expectException(UnknownStepException::class); 82 | 83 | $wizard = $this->createWizard(TestWizard::class); 84 | 85 | $wizard->show(new Request(), '1', '::step-slug::'); 86 | } 87 | 88 | public function testItGetsTheViewDataFromTheStep(): void 89 | { 90 | $renderer = new FakeResponseRenderer(); 91 | $wizard = $this->createWizard(TestWizard::class, renderer: $renderer); 92 | 93 | $wizard->show(new Request(), '1', 'step-with-view-data'); 94 | 95 | self::assertTrue($renderer->stepWasRendered(TestStepWithViewData::class, [ 96 | 'foo' => 'bar', 97 | ])); 98 | } 99 | 100 | public function testItRejectsAnInvalidFormRequest(): void 101 | { 102 | $this->expectException(ValidationException::class); 103 | 104 | $request = Request::create('::url::', 'POST', [ 105 | 'first_name' => '::first-name::', 106 | ]); 107 | $wizard = $this->createWizard(TestWizard::class); 108 | 109 | $wizard->store($request); 110 | } 111 | 112 | public function testItHandlesTheFormSubmitForTheFirstStepInTheWorkflow(): void 113 | { 114 | $request = Request::create('::url::', 'POST', [ 115 | 'first_name' => '::first-name::', 116 | 'last_name' => '::last-name::', 117 | ]); 118 | $repo = new FakeWizardRepository(); 119 | $wizard = $this->createWizard(TestWizard::class, repository: $repo); 120 | 121 | $wizard->store($request); 122 | 123 | self::assertEquals( 124 | [ 125 | 'first_name' => '::first-name::', 126 | 'last_name' => '::last-name::', 127 | ], 128 | Arr::except($repo->loadData($wizard), '_arcanist'), 129 | ); 130 | } 131 | 132 | public function testItRendersAStepForAnExistingWizardUsingTheSavedData(): void 133 | { 134 | $repo = new FakeWizardRepository([ 135 | TestWizard::class => [ 136 | 1 => [ 137 | 'first_name' => '::first-name::', 138 | 'last_name' => '::last-name::', 139 | ], 140 | ], 141 | ]); 142 | $renderer = new FakeResponseRenderer(); 143 | $wizard = $this->createWizard(TestWizard::class, repository: $repo, renderer: $renderer); 144 | 145 | $wizard->show(new Request(), '1', 'step-name'); 146 | 147 | self::assertTrue($renderer->stepWasRendered(TestStep::class, [ 148 | 'first_name' => '::first-name::', 149 | 'last_name' => '::last-name::', 150 | ])); 151 | } 152 | 153 | public function testItHandlesTheFormSubmissionForAStepInAnExistingWizard(): void 154 | { 155 | $repo = $this->createWizardRepository([ 156 | 'first_name' => '::old-first-name::', 157 | 'last_name' => '::old-last-name::', 158 | ]); 159 | $request = Request::create('::url::', 'PUT', [ 160 | 'first_name' => '::new-first-name::', 161 | 'last_name' => '::old-last-name::', 162 | ]); 163 | $wizard = $this->createWizard(TestWizard::class, repository: $repo); 164 | 165 | $wizard->update($request, '1', 'step-name'); 166 | 167 | self::assertEquals([ 168 | 'first_name' => '::new-first-name::', 169 | 'last_name' => '::old-last-name::', 170 | ], Arr::except($repo->loadData($wizard), '_arcanist')); 171 | } 172 | 173 | public function testItRedirectsToTheNextStepAfterSubmittingANewWizard(): void 174 | { 175 | $renderer = new FakeResponseRenderer(); 176 | $request = Request::create('::url::', 'PUT', [ 177 | 'first_name' => '::new-first-name::', 178 | 'last_name' => '::old-last-name::', 179 | ]); 180 | $wizard = $this->createWizard(TestWizard::class, renderer: $renderer); 181 | 182 | $wizard->store($request); 183 | 184 | self::assertTrue($renderer->didRedirectTo(TestStepWithViewData::class)); 185 | } 186 | 187 | public function testItRedirectsToTheNextStepAfterSubmittingAnExistingWizard(): void 188 | { 189 | $renderer = new FakeResponseRenderer(); 190 | $request = Request::create('::url::', 'PUT', [ 191 | 'first_name' => '::new-first-name::', 192 | 'last_name' => '::old-last-name::', 193 | ]); 194 | $wizard = $this->createWizard(TestWizard::class, renderer: $renderer); 195 | 196 | $wizard->update($request, '1', 'step-name'); 197 | 198 | self::assertTrue($renderer->didRedirectTo(TestStepWithViewData::class)); 199 | } 200 | 201 | public function testItReturnsTheWizardsTitle(): void 202 | { 203 | $wizard = $this->createWizard(TestWizard::class); 204 | 205 | $summary = $wizard->summary(); 206 | 207 | self::assertEquals('::wizard-name::', $summary['title']); 208 | } 209 | 210 | /** 211 | * @dataProvider idProvider 212 | */ 213 | public function testItReturnsTheWizardsIdInTheSummary(?int $id): void 214 | { 215 | $wizard = $this->createWizard(TestWizard::class); 216 | 217 | if (null !== $id) { 218 | $wizard->setId($id); 219 | } 220 | 221 | $summary = $wizard->summary(); 222 | 223 | self::assertEquals($id, $summary['id']); 224 | } 225 | 226 | public function idProvider(): Generator 227 | { 228 | yield from [ 229 | 'no id' => [null], 230 | 'with id' => [5], 231 | ]; 232 | } 233 | 234 | public function testItReturnsTheWizardsSlugInTheSummary(): void 235 | { 236 | $wizard = $this->createWizard(TestWizard::class); 237 | 238 | $summary = $wizard->summary(); 239 | 240 | self::assertEquals($wizard::$slug, $summary['slug']); 241 | } 242 | 243 | public function testItReturnsTheSlugOfEachStepInTheSummary(): void 244 | { 245 | $wizard = $this->createWizard(TestWizard::class); 246 | 247 | $summary = $wizard->summary(); 248 | 249 | self::assertEquals('step-name', $summary['steps'][0]['slug']); 250 | self::assertEquals('step-with-view-data', $summary['steps'][1]['slug']); 251 | } 252 | 253 | public function testItRendersInformationAboutTheCompletionOfEachStep(): void 254 | { 255 | $wizard = $this->createWizard(TestWizard::class); 256 | 257 | $summary = $wizard->summary(); 258 | 259 | self::assertTrue($summary['steps'][0]['isComplete']); 260 | self::assertFalse($summary['steps'][1]['isComplete']); 261 | } 262 | 263 | public function testItRendersTheTitleOfEachStepInTheSummary(): void 264 | { 265 | $wizard = $this->createWizard(TestWizard::class); 266 | 267 | $summary = $wizard->summary(); 268 | 269 | self::assertEquals('::step-1-name::', $summary['steps'][0]['title']); 270 | self::assertEquals('::step-2-name::', $summary['steps'][1]['title']); 271 | } 272 | 273 | public function testItMarksTheFirstStepAsActiveOnTheCreateRoute(): void 274 | { 275 | $wizard = $this->createWizard(TestWizard::class); 276 | $wizard->create(new Request()); 277 | 278 | $summary = $wizard->summary(); 279 | 280 | self::assertTrue($summary['steps'][0]['active']); 281 | self::assertFalse($summary['steps'][1]['active']); 282 | } 283 | 284 | public function testItMarksTheCurrentStepActiveForTheShowRoute(): void 285 | { 286 | $wizard = $this->createWizard(TestWizard::class); 287 | $wizard->show(new Request(), '1', 'step-with-view-data'); 288 | 289 | $summary = $wizard->summary(); 290 | 291 | self::assertFalse($summary['steps'][0]['active']); 292 | self::assertTrue($summary['steps'][1]['active']); 293 | } 294 | 295 | /** 296 | * @dataProvider wizardExistsProvider 297 | */ 298 | public function testItCanCheckIfAnExistingWizardIsBeingEdited(?int $id, bool $expected): void 299 | { 300 | $wizard = $this->createWizard(TestWizard::class); 301 | 302 | if (null !== $id) { 303 | $wizard->setId($id); 304 | } 305 | 306 | self::assertEquals($expected, $wizard->exists()); 307 | } 308 | 309 | public function wizardExistsProvider(): Generator 310 | { 311 | yield from [ 312 | 'does not exist' => [null, false], 313 | 'exists' => [1, true], 314 | ]; 315 | } 316 | 317 | public function testItIncludesTheLinkToTheStepInTheSummary(): void 318 | { 319 | $wizard = $this->createWizard(TestWizard::class); 320 | $wizard->setId(1); 321 | 322 | $summary = $wizard->summary(); 323 | 324 | self::assertEquals( 325 | route('wizard.' . $wizard::$slug . '.show', [1, 'step-name']), 326 | $summary['steps'][0]['url'], 327 | ); 328 | self::assertEquals( 329 | route('wizard.' . $wizard::$slug . '.show', [1, 'step-with-view-data']), 330 | $summary['steps'][1]['url'], 331 | ); 332 | } 333 | 334 | public function testItDoesNotIncludeTheStepUrlsIfTheWizardDoesNotExist(): void 335 | { 336 | $wizard = $this->createWizard(TestWizard::class); 337 | 338 | $summary = $wizard->summary(); 339 | 340 | self::assertNull($summary['steps'][0]['url']); 341 | self::assertNull($summary['steps'][1]['url']); 342 | } 343 | 344 | public function testItDoesNotIncludeOmittedStepsInTheSummary(): void 345 | { 346 | $wizard = $this->createWizard(OmittedStepWizard::class); 347 | 348 | $summary = $wizard->summary(); 349 | 350 | self::assertCount(1, $summary['steps']); 351 | self::assertSame('step-name', $summary['steps'][0]['slug']); 352 | } 353 | 354 | /** 355 | * @dataProvider sharedDataProvider 356 | */ 357 | public function testItIncludesSharedDataInTheViewResponse(callable $callWizard): void 358 | { 359 | $renderer = new FakeResponseRenderer(); 360 | $wizard = $this->createWizard( 361 | SharedDataWizard::class, 362 | repository: $this->createWizardRepository(wizardClass: SharedDataWizard::class), 363 | renderer: $renderer, 364 | ); 365 | 366 | $callWizard($wizard); 367 | 368 | self::assertTrue($renderer->stepWasRendered(TestStep::class, [ 369 | 'first_name' => '', 370 | 'last_name' => '', 371 | 'shared_1' => '::shared-1::', 372 | 'shared_2' => '::shared-2::', 373 | ])); 374 | } 375 | 376 | public function sharedDataProvider(): Generator 377 | { 378 | yield from [ 379 | 'create' => [ 380 | function (AbstractWizard $wizard): void { 381 | $wizard->create(new Request()); 382 | }, 383 | ], 384 | 385 | 'show' => [ 386 | function (AbstractWizard $wizard): void { 387 | $wizard->show(new Request(), '1', 'step-name'); 388 | }, 389 | ], 390 | ]; 391 | } 392 | 393 | public function beforeSaveProvider(): Generator 394 | { 395 | $validRequest = Request::create('::uri::', 'POST', [ 396 | 'first_name' => '::first-name::', 397 | 'last_name' => '::last-name::', 398 | ]); 399 | 400 | yield from [ 401 | 'store' => [ 402 | fn (AbstractWizard $wizard) => $wizard->store($validRequest), 403 | ], 404 | 'update' => [ 405 | fn (AbstractWizard $wizard) => $wizard->update($validRequest, '1', 'step-name'), 406 | ], 407 | ]; 408 | } 409 | 410 | public function testItFiresAnEventAfterTheLastStepOfTheWizardWasFinished(): void 411 | { 412 | $wizard = $this->createWizard(TestWizard::class); 413 | 414 | $wizard->update(new Request(), '1', 'step-with-view-data'); 415 | 416 | Event::assertDispatched( 417 | WizardFinishing::class, 418 | fn (WizardFinishing $event) => $event->wizard === $wizard, 419 | ); 420 | } 421 | 422 | public function testItCallsTheOnAfterCompleteActionAfterTheLastStepWasSubmitted(): void 423 | { 424 | $actionSpy = m::spy(WizardAction::class); 425 | $actionSpy->allows('execute')->andReturns(ActionResult::success()); 426 | $actionResolver = m::mock(WizardActionResolver::class); 427 | $actionResolver 428 | ->allows('resolveAction') 429 | ->with(NullAction::class) 430 | ->andReturn($actionSpy); 431 | $wizard = $this->createWizard(TestWizard::class, resolver: $actionResolver); 432 | 433 | $wizard->update(new Request(), '1', 'step-with-view-data'); 434 | 435 | $actionSpy->shouldHaveReceived('execute') 436 | ->once(); 437 | } 438 | 439 | public function testItPassesAllGatheredDataToTheActionByDefault(): void 440 | { 441 | $actionSpy = new class() extends WizardAction { 442 | /** 443 | * @var array 444 | */ 445 | public array $payload = []; 446 | 447 | /** 448 | * @param array $payload 449 | */ 450 | public function execute($payload): ActionResult 451 | { 452 | $this->payload = $payload; 453 | 454 | return $this->success(); 455 | } 456 | }; 457 | $actionResolver = m::mock(WizardActionResolver::class); 458 | $actionResolver 459 | ->allows('resolveAction') 460 | ->andReturn($actionSpy); 461 | $wizard = $this->createWizard(SharedDataWizard::class, resolver: $actionResolver); 462 | 463 | $wizard->update(Request::create('::url::', 'POST', [ 464 | 'first_name' => '::first-name::', 465 | 'last_name' => '::last-name::', 466 | ]), '1', 'step-name'); 467 | 468 | self::assertEquals( 469 | ['first_name' => '::first-name::', 'last_name' => '::last-name::'], 470 | $actionSpy->payload, 471 | ); 472 | } 473 | 474 | public function testItFiresAnEventAfterTheOnCompleteCallbackWasRan(): void 475 | { 476 | $wizard = $this->createWizard(TestWizard::class); 477 | 478 | $wizard->update(new Request(), '1', 'step-with-view-data'); 479 | 480 | Event::assertDispatched( 481 | WizardFinished::class, 482 | fn (WizardFinished $event) => $event->wizard === $wizard, 483 | ); 484 | } 485 | 486 | public function testItCallsTheOnAfterCompleteHookOfTheWizard(): void 487 | { 488 | $wizard = $this->createWizard(TestWizard::class); 489 | 490 | $wizard->update(new Request(), '1', 'step-with-view-data'); 491 | 492 | self::assertEquals(1, $_SERVER['__onAfterComplete.called']); 493 | } 494 | 495 | /** 496 | * @dataProvider beforeSaveProvider 497 | */ 498 | public function testItFiresAnEventBeforeTheWizardGetsSaved(callable $callwizard): void 499 | { 500 | $wizard = $this->createWizard(TestWizard::class); 501 | 502 | $callwizard($wizard); 503 | 504 | Event::assertDispatched( 505 | WizardSaving::class, 506 | fn (WizardSaving $e) => $e->wizard === $wizard, 507 | ); 508 | } 509 | 510 | /** 511 | * @dataProvider afterSaveProvider 512 | */ 513 | public function testItFiresAnEventAfterAnWizardWasLoaded(callable $callwizard): void 514 | { 515 | $wizard = $this->createWizard(TestWizard::class); 516 | 517 | $callwizard($wizard); 518 | 519 | Event::assertDispatched( 520 | WizardLoaded::class, 521 | fn (WizardLoaded $e) => $e->wizard === $wizard, 522 | ); 523 | } 524 | 525 | public function afterSaveProvider(): Generator 526 | { 527 | yield from [ 528 | 'update' => [ 529 | function (AbstractWizard $wizard): void { 530 | $wizard->update(new Request(), '1', 'step-with-view-data'); 531 | }, 532 | ], 533 | 534 | 'show' => [ 535 | function (AbstractWizard $wizard): void { 536 | $wizard->show(new Request(), '1', 'step-with-view-data'); 537 | }, 538 | ], 539 | ]; 540 | } 541 | 542 | public function testItCanBeDeleted(): void 543 | { 544 | $this->expectException(NotFoundHttpException::class); 545 | 546 | $wizard = $this->createWizard(TestWizard::class); 547 | 548 | $wizard->destroy(new Request(), '1'); 549 | 550 | $wizard->show(new Request(), '1', 'step-name'); 551 | } 552 | 553 | public function testItRedirectsToTheDefaultRouteAfterTheWizardHasBeenDeleted(): void 554 | { 555 | config(['arcanist.redirect_url' => '::redirect-url::']); 556 | 557 | $wizard = $this->createWizard(TestWizard::class); 558 | 559 | $response = new TestResponse($wizard->destroy(new Request(), '1')); 560 | 561 | $response->assertRedirect('::redirect-url::'); 562 | } 563 | 564 | public function testItRedirectsToTheCorrectUrlIfTheDefaultUrlWasOverwritten(): void 565 | { 566 | $wizard = $this->createWizard(SharedDataWizard::class); 567 | 568 | $response = new TestResponse($wizard->destroy(new Request(), '1')); 569 | 570 | $response->assertRedirect('::other-route::'); 571 | } 572 | 573 | public function testItCallsTheOnAfterDeleteHookOfTheWizard(): void 574 | { 575 | $wizard = $this->createWizard(TestWizard::class); 576 | 577 | $wizard->destroy(new Request(), '1'); 578 | 579 | self::assertEquals(1, $_SERVER['__onAfterDelete.called']); 580 | } 581 | 582 | /** 583 | * @dataProvider resumeWizardProvider 584 | */ 585 | public function testItRedirectsToTheNextUncompletedStepIfNoStepSlugWasGiven(callable $createwizard, string $expectedStep): void 586 | { 587 | $renderer = new FakeResponseRenderer(); 588 | $wizard = $createwizard($renderer); 589 | 590 | $wizard->show(new Request(), '1'); 591 | 592 | self::assertTrue($renderer->didRedirectTo($expectedStep)); 593 | } 594 | 595 | public function resumeWizardProvider(): Generator 596 | { 597 | yield from [ 598 | [ 599 | function (ResponseRenderer $renderer) { 600 | return $this->createWizard(TestWizard::class, renderer: $renderer); 601 | }, 602 | TestStepWithViewData::class, 603 | ], 604 | [ 605 | function (ResponseRenderer $renderer) { 606 | return $this->createWizard(MultiStepWizard::class, renderer: $renderer); 607 | }, 608 | TestStepWithViewData::class, 609 | ], 610 | ]; 611 | } 612 | 613 | /** 614 | * @dataProvider errorWizardProvider 615 | */ 616 | public function testItRedirectsToTheSameStepWithAnErrorIfTheStepWasNotCompletedSuccessfully(callable $callWizard): void 617 | { 618 | $renderer = new FakeResponseRenderer(); 619 | $wizard = $this->createWizard(ErrorWizard::class, renderer: $renderer); 620 | 621 | $callWizard($wizard); 622 | 623 | self::assertTrue( 624 | $renderer->didRedirectWithError(ErrorStep::class, '::error-message::'), 625 | ); 626 | } 627 | 628 | public function testItRedirectsBackToLastStepWithAnErrorIfTheActionWasNotSuccessful(): void 629 | { 630 | $renderer = new FakeResponseRenderer(); 631 | $resolver = m::mock(WizardActionResolver::class); 632 | $resolver->allows('resolveAction') 633 | ->andReturns(new class() extends WizardAction { 634 | public function execute(mixed $payload): ActionResult 635 | { 636 | return $this->failure('::message::'); 637 | } 638 | }); 639 | $wizard = $this->createWizard(TestWizard::class, renderer: $renderer, resolver: $resolver); 640 | 641 | $wizard->update(new Request(), '1', 'step-with-view-data'); 642 | 643 | self::assertTrue( 644 | $renderer->didRedirectWithError(TestStepWithViewData::class, '::message::'), 645 | ); 646 | } 647 | 648 | public function errorWizardProvider(): Generator 649 | { 650 | yield from [ 651 | 'store' => [ 652 | function (AbstractWizard $wizard): void { 653 | $wizard->store(new Request()); 654 | }, 655 | ], 656 | 657 | 'update' => [ 658 | function (AbstractWizard $wizard): void { 659 | $wizard->update(new Request(), '1', '::error-step::'); 660 | }, 661 | ], 662 | ]; 663 | } 664 | 665 | public function testItMarksAStepAsCompletedIfItWasSubmittedSuccessfullyOnce(): void 666 | { 667 | $repo = $this->createWizardRepository(); 668 | $wizard = $this->createWizard(TestWizard::class, repository: $repo); 669 | $request = Request::create('::uri::', 'POST', [ 670 | 'first_name' => '::first-name::', 671 | 'last_name' => '::first-name::', 672 | ]); 673 | 674 | $wizard->update($request, '1', 'step-name'); 675 | 676 | /** @phpstan-ignore-next-line */ 677 | self::assertTrue($repo->loadData($wizard)['_arcanist']['step-name']); 678 | } 679 | 680 | public function testItDoesNotMarkAStepAsCompleteIfItFailed(): void 681 | { 682 | $repo = $this->createWizardRepository(wizardClass: ErrorWizard::class); 683 | $wizard = $this->createWizard(ErrorWizard::class, repository: $repo); 684 | 685 | $wizard->update(new Request(), '1', '::error-step::'); 686 | 687 | self::assertNull( 688 | /** @phpstan-ignore-next-line */ 689 | $repo->loadData($wizard)['_arcanist']['::error-step::'] ?? null, 690 | ); 691 | } 692 | 693 | public function testItMergesInformationWithInformationAboutAlreadyCompletedSteps(): void 694 | { 695 | $repo = $this->createWizardRepository([ 696 | '_arcanist' => [ 697 | 'regular-step' => true, 698 | ], 699 | ]); 700 | $wizard = $this->createWizard(TestWizard::class, repository: $repo); 701 | $wizard->setId(1); 702 | 703 | $wizard->update(new Request(), '1', 'step-with-view-data'); 704 | 705 | self::assertEquals([ 706 | 'regular-step' => true, 707 | 'step-with-view-data' => true, 708 | ], $repo->loadData($wizard)['_arcanist']); 709 | } 710 | } 711 | 712 | class TestWizard extends AbstractWizard 713 | { 714 | public static string $slug = 'wizard-name'; 715 | public static string $title = '::wizard-name::'; 716 | protected array $steps = [ 717 | TestStep::class, 718 | TestStepWithViewData::class, 719 | ]; 720 | 721 | protected function onAfterComplete(ActionResult $result): Response|Responsable|Renderable 722 | { 723 | ++$_SERVER['__onAfterComplete.called']; 724 | 725 | return redirect()->back(); 726 | } 727 | 728 | protected function onAfterDelete(): Response|Responsable|Renderable 729 | { 730 | ++$_SERVER['__onAfterDelete.called']; 731 | 732 | return parent::onAfterDelete(); 733 | } 734 | 735 | protected function beforeDelete(Request $request): void 736 | { 737 | ++$_SERVER['__beforeDelete.called']; 738 | } 739 | 740 | protected function cancelText(): string 741 | { 742 | return '::cancel-text::'; 743 | } 744 | } 745 | 746 | class MultiStepWizard extends AbstractWizard 747 | { 748 | protected array $steps = [ 749 | TestStep::class, 750 | DummyStep::class, 751 | TestStepWithViewData::class, 752 | ]; 753 | } 754 | 755 | class OmittedStepWizard extends AbstractWizard 756 | { 757 | protected array $steps = [ 758 | TestStep::class, 759 | OmittedStep::class, 760 | ]; 761 | } 762 | 763 | class SharedDataWizard extends AbstractWizard 764 | { 765 | protected array $steps = [ 766 | TestStep::class, 767 | ]; 768 | 769 | public function sharedData(Request $request): array 770 | { 771 | return [ 772 | 'shared_1' => '::shared-1::', 773 | 'shared_2' => '::shared-2::', 774 | ]; 775 | } 776 | 777 | protected function onAfterComplete(ActionResult $result): RedirectResponse 778 | { 779 | return redirect()->back(); 780 | } 781 | 782 | protected function redirectTo(): string 783 | { 784 | return '::other-route::'; 785 | } 786 | } 787 | 788 | class ErrorWizard extends AbstractWizard 789 | { 790 | protected array $steps = [ 791 | ErrorStep::class, 792 | ]; 793 | } 794 | 795 | class TestStep extends WizardStep 796 | { 797 | public string $title = '::step-1-name::'; 798 | public string $slug = 'step-name'; 799 | 800 | public function fields(): array 801 | { 802 | return [ 803 | Field::make('first_name') 804 | ->rules(['required']), 805 | 806 | Field::make('last_name') 807 | ->rules(['required']), 808 | ]; 809 | } 810 | 811 | public function viewData(Request $request): array 812 | { 813 | return [ 814 | 'first_name' => $this->data('first_name'), 815 | 'last_name' => $this->data('last_name'), 816 | ]; 817 | } 818 | 819 | public function isComplete(): bool 820 | { 821 | return true; 822 | } 823 | 824 | /** 825 | * @param array $data 826 | */ 827 | public function beforeSaving(Request $request, array $data): void 828 | { 829 | ++$_SERVER['__beforeSaving.called']; 830 | } 831 | } 832 | 833 | class TestStepWithViewData extends WizardStep 834 | { 835 | public string $title = '::step-2-name::'; 836 | public string $slug = 'step-with-view-data'; 837 | 838 | public function viewData(Request $request): array 839 | { 840 | return ['foo' => 'bar']; 841 | } 842 | } 843 | 844 | class OmittedStep extends WizardStep 845 | { 846 | public string $slug = 'omitted-step-name'; 847 | 848 | public function omit(): bool 849 | { 850 | return true; 851 | } 852 | } 853 | 854 | class DummyStep extends WizardStep 855 | { 856 | public function isComplete(): bool 857 | { 858 | return true; 859 | } 860 | } 861 | 862 | class ErrorStep extends WizardStep 863 | { 864 | public string $slug = '::error-step::'; 865 | 866 | protected function handle(Request $request, array $payload): StepResult 867 | { 868 | return $this->error('::error-message::'); 869 | } 870 | } 871 | -------------------------------------------------------------------------------- /tests/WizardTestCase.php: -------------------------------------------------------------------------------- 1 | $wizardClass 38 | */ 39 | protected function createWizard( 40 | string $wizardClass, 41 | ?WizardRepository $repository = null, 42 | ?ResponseRenderer $renderer = null, 43 | ?WizardActionResolver $resolver = null, 44 | ): AbstractWizard { 45 | $repository ??= $this->createWizardRepository(wizardClass: $wizardClass); 46 | $renderer ??= new FakeResponseRenderer(); 47 | $resolver ??= new class() implements WizardActionResolver { 48 | public function resolveAction(string $actionClass): WizardAction 49 | { 50 | $action = m::mock(WizardAction::class); 51 | $action->allows('execute')->andReturn(ActionResult::success()); 52 | 53 | return $action; 54 | } 55 | }; 56 | 57 | return new $wizardClass($repository, $renderer, $resolver); 58 | } 59 | 60 | /** 61 | * @param null|class-string $wizardClass 62 | * @param array $data 63 | */ 64 | protected function createWizardRepository(array $data = [], ?string $wizardClass = null): FakeWizardRepository 65 | { 66 | return new FakeWizardRepository([ 67 | $wizardClass ?: TestWizard::class => [ 68 | 1 => $data, 69 | ], 70 | ]); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/__snapshots__/files/WizardMakeCommandTest__testItPrefillsTheGeneratedWizardsSlugProperty__1.php: -------------------------------------------------------------------------------- 1 | rules(['required', 'unique:users,username']) 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/__snapshots__/files/WizardStepMakeCommandTest__testItPrefillsTheStepsTitle__1.php: -------------------------------------------------------------------------------- 1 | rules(['required', 'unique:users,username']) 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/views/wizards/blade-wizard/blade-step.blade.php: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laravel-arcanist/arcanist/7f437a8cfe9d0906fb831a237a4ee5a3aa2c238b/tests/views/wizards/blade-wizard/blade-step.blade.php --------------------------------------------------------------------------------