diff --git a/.cursor/cli.json b/.cursor/cli.json new file mode 100644 index 0000000..59f0178 --- /dev/null +++ b/.cursor/cli.json @@ -0,0 +1,5 @@ +{ + "attribution": { + "attributePRsToAgent": false + } +} diff --git a/.cursor/rules/pull-requests.mdc b/.cursor/rules/pull-requests.mdc new file mode 100644 index 0000000..4699190 --- /dev/null +++ b/.cursor/rules/pull-requests.mdc @@ -0,0 +1,11 @@ +--- +description: Do not add Cursor branding to pull requests +alwaysApply: true +--- + +# Pull requests + +When creating or editing GitHub pull request descriptions (`gh pr create`, `gh pr edit`): + +- Do **not** append "Made with Cursor", "Made with [Cursor](https://cursor.com)", or any similar agent/IDE attribution footer. +- PR bodies should contain only the summary, notes, and test plan relevant to the change. diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml deleted file mode 100644 index 0bc0190..0000000 --- a/.github/workflows/fix-php-code-style-issues.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: "fix-php-code-styling" - -on: - push: - paths: - - '**.php' - -permissions: - contents: write - -jobs: - php-code-styling: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - ref: ${{ github.head_ref }} - - - name: Fix PHP code style issues - uses: aglipanci/laravel-pint-action@2.6 - - - name: Commit changes - uses: stefanzweifel/git-auto-commit-action@v7 - with: - commit_message: Fix styling diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 4352132..7d961d9 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -7,14 +7,12 @@ on: - '**.php' - 'phpstan.neon.dist' - 'composer.json' - - 'composer.lock' pull_request: branches: [ main, 5.x ] paths: - '**.php' - 'phpstan.neon.dist' - 'composer.json' - - 'composer.lock' jobs: phpstan: @@ -35,7 +33,7 @@ jobs: - name: Install dependencies uses: ramsey/composer-install@v3 with: - composer-options: '--prefer-dist --no-scripts' + composer-options: '--prefer-dist' - name: Run PHPStan run: ./vendor/bin/phpstan --error-format=github diff --git a/.github/workflows/run-coverage.yml b/.github/workflows/run-coverage.yml new file mode 100644 index 0000000..c62d8b0 --- /dev/null +++ b/.github/workflows/run-coverage.yml @@ -0,0 +1,56 @@ +name: run-coverage + +on: + push: + branches: [ main, 5.x ] + pull_request: + branches: [ main, 5.x ] + +concurrency: + group: coverage-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + coverage: + runs-on: ubuntu-latest + timeout-minutes: 15 + name: Code coverage · PHP 8.4 · Laravel 13 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, intl, gd, exif, iconv, fileinfo + coverage: pcov + tools: composer:v2 + + - name: Install dependencies + env: + COMPOSER_PROCESS_TIMEOUT: 0 + run: | + composer require \ + "laravel/framework:^13.10" \ + "orchestra/testbench:^11.0" \ + "pestphp/pest-plugin-laravel:^4.0" \ + "pestphp/pest-plugin-livewire:^4.0" \ + --dev \ + --no-interaction \ + --no-update + composer update --prefer-stable --prefer-dist --no-interaction --no-scripts + composer dump-autoload + + - name: Run tests with coverage + run: | + mkdir -p build/logs + vendor/bin/pest --ci --coverage --min=98 --coverage-clover=build/logs/clover.xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: build/logs/clover.xml + fail_ci_if_error: false diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f6e78f1..1833532 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -21,37 +21,39 @@ jobs: - php: '8.3' laravel: ^12.0 testbench: ^10.0 - pest: ^3.8 - pest-laravel: ^3.1 - pest-livewire: ^3.0 os: ubuntu-latest - php: '8.4' laravel: ^12.0 testbench: ^10.0 - pest: ^3.8 - pest-laravel: ^3.1 - pest-livewire: ^3.0 os: ubuntu-latest - php: '8.3' - laravel: ^13.0 + laravel: ^13.10 testbench: ^11.0 pest: ^4.4 - pest-laravel: ^4.1 - pest-livewire: ^4.1 + pest-plugin-laravel: ^4.0 + pest-plugin-livewire: ^4.0 os: ubuntu-latest - php: '8.4' - laravel: ^13.0 + laravel: ^13.10 testbench: ^11.0 pest: ^4.4 - pest-laravel: ^4.1 - pest-livewire: ^4.1 + pest-plugin-laravel: ^4.0 + pest-plugin-livewire: ^4.0 + os: ubuntu-latest + - php: '8.5' + laravel: ^12.0 + testbench: ^10.0 + os: ubuntu-latest + - php: '8.5' + laravel: ^13.10 + testbench: ^11.0 + pest: ^4.4 + pest-plugin-laravel: ^4.0 + pest-plugin-livewire: ^4.0 os: ubuntu-latest - php: '8.3' laravel: ^12.0 testbench: ^10.0 - pest: ^3.8 - pest-laravel: ^3.1 - pest-livewire: ^3.0 os: windows-latest name: PHP ${{ matrix.php }} · Laravel ${{ matrix.laravel }} · ${{ matrix.os }} @@ -69,20 +71,31 @@ jobs: tools: composer:v2 - name: Install dependencies + shell: bash env: COMPOSER_PROCESS_TIMEOUT: 0 run: | - composer require \ - "laravel/framework:${{ matrix.laravel }}" \ - "orchestra/testbench:${{ matrix.testbench }}" \ - "pestphp/pest:${{ matrix.pest }}" \ - "pestphp/pest-plugin-laravel:${{ matrix.pest-laravel }}" \ - "pestphp/pest-plugin-livewire:${{ matrix.pest-livewire }}" \ - --dev \ - --no-interaction \ - --no-update + PACKAGES=( + "laravel/framework:${{ matrix.laravel }}" + "orchestra/testbench:${{ matrix.testbench }}" + ) + + if [ -n "${{ matrix.pest }}" ]; then + PACKAGES+=("pestphp/pest:${{ matrix.pest }}") + fi + + if [ -n "${{ matrix.pest-plugin-laravel }}" ]; then + PACKAGES+=("pestphp/pest-plugin-laravel:${{ matrix.pest-plugin-laravel }}") + fi + + if [ -n "${{ matrix.pest-plugin-livewire }}" ]; then + PACKAGES+=("pestphp/pest-plugin-livewire:${{ matrix.pest-plugin-livewire }}") + fi + + composer require "${PACKAGES[@]}" --dev --no-interaction --no-update composer update --prefer-stable --prefer-dist --no-interaction --no-scripts composer dump-autoload - name: Run tests + shell: bash run: vendor/bin/pest --ci diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index e687dad..cc15613 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -6,6 +6,7 @@ on: permissions: contents: write + pull-requests: write jobs: update: @@ -15,7 +16,33 @@ jobs: - name: Checkout code uses: actions/checkout@v6 with: - ref: main + ref: ${{ github.event.release.tag_name }} + fetch-depth: 0 + + - name: Resolve changelog branch + id: changelog-branch + run: | + target="${{ github.event.release.target_commitish }}" + + if git show-ref --verify --quiet "refs/remotes/origin/${target}"; then + echo "name=${target}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + branch="$(git branch -r --contains "${{ github.event.release.tag_name }}" \ + | sed 's|^[[:space:]]*origin/||' \ + | grep -Ev 'HEAD|pull' \ + | head -1)" + + if [ -z "${branch}" ]; then + echo "Could not resolve branch for tag ${{ github.event.release.tag_name }}" >&2 + exit 1 + fi + + echo "name=${branch}" >> "$GITHUB_OUTPUT" + + - name: Checkout changelog branch + run: git checkout -B "${{ steps.changelog-branch.outputs.name }}" "origin/${{ steps.changelog-branch.outputs.name }}" - name: Update Changelog uses: stefanzweifel/changelog-updater-action@v1 @@ -23,9 +50,30 @@ jobs: latest-version: ${{ github.event.release.name }} release-notes: ${{ github.event.release.body }} - - name: Commit updated CHANGELOG - uses: stefanzweifel/git-auto-commit-action@v7 + - name: Create pull request + id: create-pull-request + uses: peter-evans/create-pull-request@v7 with: - branch: main - commit_message: Update CHANGELOG - file_pattern: CHANGELOG.md + base: ${{ steps.changelog-branch.outputs.name }} + branch: changelog/${{ github.event.release.tag_name }} + commit-message: Update CHANGELOG + title: "docs: update CHANGELOG for ${{ github.event.release.name }}" + body: | + Automated changelog update for release ${{ github.event.release.name }}. + + Tag: ${{ github.event.release.tag_name }} + Branch: ${{ steps.changelog-branch.outputs.name }} + Triggered by: ${{ github.event.release.html_url }} + delete-branch: true + + - name: Merge pull request + if: steps.create-pull-request.outputs.pull-request-operation == 'created' || steps.create-pull-request.outputs.pull-request-operation == 'updated' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR="${{ steps.create-pull-request.outputs.pull-request-number }}" + + # Auto-merge only works when required checks are still pending. Changelog PRs + # target branches without required status checks, so GitHub rejects --auto with + # "Pull request is in clean status" and the PR must be merged directly instead. + gh pr merge "$PR" --auto --squash || gh pr merge "$PR" --squash --delete-branch diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e4aae..f813ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to `modules` will be documented in this file. +## v5.3.2 - 2026-06-13 + +### What's Changed + +* docs: update CHANGELOG for v5.3.1 by @github-actions[bot] in https://github.com/coolsam726/filament-modules/pull/183 +* fix: merge changelog PR when auto-merge is unavailable by @coolsam726 in https://github.com/coolsam726/filament-modules/pull/184 + +### New Contributors + +* @github-actions[bot] made their first contribution in https://github.com/coolsam726/filament-modules/pull/183 + +**Full Changelog**: https://github.com/coolsam726/filament-modules/compare/v5.3.1...v5.3.2 + +## v5.3.1 - 2026-06-13 + +### What's Changed + +* fix: changelog workflow and improve test coverage by @coolsam726 in https://github.com/coolsam726/filament-modules/pull/182 + +**Full Changelog**: https://github.com/coolsam726/filament-modules/compare/v5.3.0...v5.3.1 + ## v5.1.0 - 2026-01-26 ### What's Changed diff --git a/README.md b/README.md index 9d1c199..01cb6c6 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ # Filament Modules v5.x -[![Latest Version on Packagist](https://img.shields.io/packagist/v/coolsam/modules.svg?style=flat-square)](https://packagist.org/packages/coolsam/modules) -[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/savannabits/filament-modules/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/savannabits/filament-modules/actions?query=workflow%3Arun-tests+branch%3Amain) -[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/savannabits/filament-modules/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/savannabits/filament-modules/actions?query=workflow%3Afix-php-code-style+branch%3Amain) -[![Total Downloads](https://img.shields.io/packagist/dt/coolsam/modules.svg?style=flat-square)](https://packagist.org/packages/coolsam/modules) +[![Latest Version on Packagist](https://img.shields.io/packagist/v/coolsam/modules.svg?style=for-the-badge)](https://packagist.org/packages/coolsam/modules) +[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/coolsam726/filament-modules/run-tests.yml?branch=5.x&label=tests&style=for-the-badge)](https://github.com/coolsam726/filament-modules/actions?query=workflow%3Arun-tests+branch%3A5.x) +[![Codecov](https://img.shields.io/codecov/c/github/coolsam726/filament-modules/5.x?style=for-the-badge&logo=codecov)](https://app.codecov.io/gh/coolsam726/filament-modules/tree/5.x) +[![Total Downloads](https://img.shields.io/packagist/dt/coolsam/modules.svg?style=for-the-badge)](https://packagist.org/packages/coolsam/modules) -> **NOTE:** This documentation is for **version 5.x** of the package, which supports **Laravel 11+**, **Filament 4.x** +> **NOTE:** This documentation is for **version 5.x** of the package, which supports **Laravel 11, 12, and 13**, **Filament 4.x and 5.x** > and **nwidart/laravel-modules 11+**. If you are using Filament 3.x, please refer -> to [4.x documentation](https://github.com/savannabits/filament-modules/tree/4.x) -> or [3.x documentation](https://github.com/savannabits/filament-modules/tree/3.x) if you are using Laravel 10. +> to [4.x documentation](https://github.com/coolsam726/filament-modules/tree/4.x) +> or [3.x documentation](https://github.com/coolsam726/filament-modules/tree/3.x) if you are using Laravel 10. -![image](https://github.com/savannabits/filament-modules/assets/5610289/ba191f1d-b5ee-4eb9-9db7-d42a19cc8d38) +![image](https://github.com/coolsam726/filament-modules/assets/5610289/ba191f1d-b5ee-4eb9-9db7-d42a19cc8d38) This package brings the power of modules to Laravel Filament. It allows you to organize your filament code into fully autonomous modules that can be easily shared and reused across multiple projects. @@ -39,16 +39,16 @@ The following is a table showing a matrix of supported filament and laravel vers | Package Version | Laravel Version | Filament Version | nwidart/laravel-modules Version | |-----------------|-----------------|------------------|---------------------------------| -| 5.x | 11.x and 12.x | 4.x | 11.x or 12.x | +| 5.x | 11.x, 12.x, and 13.x | 4.x and 5.x | 11.x, 12.x, or 13.x | | 4.x | 11.x and 12.x | 3.x | 11.x or 12.x | | 3.x | 10.x | 3.x | 11.x | v5.x of this package requires the following dependencies: -- Laravel 11.x or 12.x -- Filament 4.x or higher -- PHP 8.2 or higher -- nwidart/laravel-modules 11.x or 12.x +- Laravel 11.x, 12.x, or 13.x +- Filament 4.x or 5.x +- PHP 8.3 or higher +- nwidart/laravel-modules 11.x, 12.x, or 13.x ## Installation @@ -58,10 +58,9 @@ You can install the package via composer: composer require coolsam/modules ``` -This will automatically install `nwidart/laravel-modules: ^11` (for Laravel 11) or `nwidart/laravel-modules: ^12` (for -Laravel 12) as well. Make sure you go through -the [documentation](https://laravelmodules.com/docs/12) to understand how to use the package and to configure it -properly before proceeding. +This will automatically install a compatible `nwidart/laravel-modules` release (`^11` on Laravel 11, `^12` on Laravel 12, +or `^13` on Laravel 13). Make sure you go through the [documentation](https://laravelmodules.com/docs/v13) to +understand how to use the package and to configure it properly before proceeding. **Task: Configure your Laravel Modules first before continuing.** @@ -278,6 +277,18 @@ php artisan module:filament:make-panel ``` Follow the interactive prompts to create a new panel in your module. +### Scoping resources, clusters, and pages to a panel + +Each module can register one or more Filament panels through `module:make:filament-panel`. Resources, pages, widgets, and clusters belong to a panel through that panel's `PanelProvider` — typically via `discoverResources()`, `discoverPages()`, and `discoverWidgets()` inside the provider's `panel()` method. + +To keep a resource or cluster in a single panel: + +1. Generate the resource/cluster inside the target module (and panel subdirectory, if you use per-panel folders). +2. Ensure only the intended module `*PanelProvider` discovers that directory/namespace. +3. Register `ModulesPlugin` on the main/admin panel so module panel links appear in navigation when `filament-modules.mode` supports panels. + +Filament's own panel discovery rules apply; this package wires modules and panels together but does not override Filament's per-panel registration model. + ### Protecting your resources, pages and widgets (Access Control) - WIP diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..23bf898 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,18 @@ +coverage: + status: + project: + default: + target: 98% + threshold: 0% + patch: + default: + target: 98% + threshold: 0% + +comment: + require_changes: true + +ignore: + - src/Commands/** + - src/Concerns/CanManipulateFiles.php + - src/Concerns/CanGenerateModulePanels.php diff --git a/composer.json b/composer.json index e651283..1c33da0 100644 --- a/composer.json +++ b/composer.json @@ -7,10 +7,10 @@ "FilamentModules", "filament" ], - "homepage": "https://github.com/savannabits/filament-modules", + "homepage": "https://github.com/coolsam726/filament-modules", "support": { - "issues": "https://github.com/savannabits/filament-modules/issues", - "source": "https://github.com/savannabits/filament-modules" + "issues": "https://github.com/coolsam726/filament-modules/issues", + "source": "https://github.com/coolsam726/filament-modules" }, "license": "MIT", "authors": [ @@ -29,12 +29,13 @@ }, "require-dev": { "barryvdh/laravel-ide-helper": "^3.5", + "larastan/larastan": "^3.1.0", + "laravel/framework": "^12.0", "laravel/pint": "^1.0", - "nunomaduro/larastan": "^3.1.0", - "orchestra/testbench": "^10.0|^11.0", + "orchestra/testbench": "^10.0", "pestphp/pest": "^3.8|^4.4", - "pestphp/pest-plugin-laravel": "^3.1|^4.1", - "pestphp/pest-plugin-livewire": "^3.0|^4.1", + "pestphp/pest-plugin-laravel": "^3.1|^4.0", + "pestphp/pest-plugin-livewire": "^3.0|^4.0", "phpstan/extension-installer": "^1.4.3", "spatie/laravel-ray": "^1.39" }, @@ -56,7 +57,7 @@ ], "analyse": "vendor/bin/phpstan analyse", "test": "vendor/bin/pest", - "test-coverage": "vendor/bin/pest --coverage", + "test-coverage": "vendor/bin/pest --ci --coverage --min=98 --coverage-html=build/coverage --coverage-text=build/coverage.txt --coverage-clover=build/logs/clover.xml", "format": "vendor/bin/pint", "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", "prepare": "@php vendor/bin/testbench package:discover --ansi", diff --git a/config/filament-modules.php b/config/filament-modules.php index 59d93d3..3f6185b 100644 --- a/config/filament-modules.php +++ b/config/filament-modules.php @@ -1,5 +1,8 @@ 'nwidart', // module discovery driver (nwidart, future drivers) @@ -10,7 +13,7 @@ 'enabled' => false, 'context' => null, // e.g. App\Support\FilamentTenantContext::class ], - 'mode' => \Coolsam\Modules\Enums\ConfigMode::BOTH->value, // 'plugins' or 'panels', determines how the Filament Modules are registered + 'mode' => ConfigMode::BOTH->value, // 'plugins' or 'panels', determines how the Filament Modules are registered 'auto-register-plugins' => true, // whether to auto-register plugins from various modules in the Panel. Only relevant if 'mode' is set to 'plugins'. 'clusters' => [ 'enabled' => true, // whether to enable the clusters feature which allows you to group each module's filament resources and pages into a cluster @@ -18,7 +21,7 @@ ], 'panels' => [ 'group' => 'Panels', // the group name for the panels in the navigation - 'group-icon' => \Filament\Support\Icons\Heroicon::OutlinedRectangleStack, + 'group-icon' => Heroicon::OutlinedRectangleStack, 'group-sort' => 0, // the sort order of the panels group in the navigation 'open-in-new-tab' => false, // whether to open the panels in a new tab ], diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e69de29..0cdbc68 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -0,0 +1,73 @@ +parameters: + ignoreErrors: + - + message: '#^Access to an undefined property Coolsam\\Modules\\Commands\\ModuleMakeFilamentClusterCommand\:\:\$files\.$#' + identifier: property.notFound + count: 1 + path: src/Commands/ModuleMakeFilamentClusterCommand.php + + - + message: '#^Access to an undefined property Coolsam\\Modules\\Commands\\ModuleMakeFilamentClusterCommand\:\:\$type\.$#' + identifier: property.notFound + count: 1 + path: src/Commands/ModuleMakeFilamentClusterCommand.php + + - + message: '#^Call to an undefined method Coolsam\\Modules\\Commands\\ModuleMakeFilamentClusterCommand\:\:replaceClass\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Commands/ModuleMakeFilamentClusterCommand.php + + - + message: '#^Access to an undefined property Coolsam\\Modules\\Commands\\ModuleMakeFilamentPageCommand\:\:\$files\.$#' + identifier: property.notFound + count: 1 + path: src/Commands/ModuleMakeFilamentPageCommand.php + + - + message: '#^Call to an undefined method Coolsam\\Modules\\Commands\\ModuleMakeFilamentPageCommand\:\:replaceClass\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Commands/ModuleMakeFilamentPageCommand.php + + - + message: '#^Access to an undefined property Coolsam\\Modules\\Commands\\ModuleMakeFilamentPanelCommand\:\:\$files\.$#' + identifier: property.notFound + count: 1 + path: src/Commands/ModuleMakeFilamentPanelCommand.php + + - + message: '#^Access to an undefined property Coolsam\\Modules\\Commands\\ModuleMakeFilamentPanelCommand\:\:\$type\.$#' + identifier: property.notFound + count: 1 + path: src/Commands/ModuleMakeFilamentPanelCommand.php + + - + message: '#^Call to an undefined method Coolsam\\Modules\\Commands\\ModuleMakeFilamentPanelCommand\:\:replaceClass\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Commands/ModuleMakeFilamentPanelCommand.php + + - + message: '#^Access to an undefined property Coolsam\\Modules\\Commands\\ModuleMakeFilamentResourceCommand\:\:\$files\.$#' + identifier: property.notFound + count: 2 + path: src/Commands/ModuleMakeFilamentResourceCommand.php + + - + message: '#^Call to an undefined method Coolsam\\Modules\\Commands\\ModuleMakeFilamentResourceCommand\:\:replaceClass\(\)\.$#' + identifier: method.notFound + count: 2 + path: src/Commands/ModuleMakeFilamentResourceCommand.php + + - + message: '#^Access to an undefined property Coolsam\\Modules\\Commands\\ModuleMakeFilamentWidgetCommand\:\:\$files\.$#' + identifier: property.notFound + count: 1 + path: src/Commands/ModuleMakeFilamentWidgetCommand.php + + - + message: '#^Call to an undefined method Coolsam\\Modules\\Commands\\ModuleMakeFilamentWidgetCommand\:\:replaceClass\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Commands/ModuleMakeFilamentWidgetCommand.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 03c804d..846bae3 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,19 +1,20 @@ includes: - phpstan-baseline.neon - - vendor/nunomad/phpstan-filament/extension.neon parameters: - larastan: - analyze: - - src level: 4 + stubFiles: + - phpstan/stubs/Module.stub paths: - src tmpDir: build/phpstan + treatPhpDocTypesAsCertain: false checkOctaneCompatibility: true checkModelProperties: false reportUnmatchedIgnoredErrors: false reportMaybes: false + ignoreErrors: + - identifier: trait.unused fileExtensions: - php - php.stub diff --git a/phpstan/stubs/Module.stub b/phpstan/stubs/Module.stub new file mode 100644 index 0000000..76b7f58 --- /dev/null +++ b/phpstan/stubs/Module.stub @@ -0,0 +1,18 @@ +tests - - - - - - - @@ -34,5 +27,10 @@ ./src + + ./src/Commands + ./src/Concerns/CanManipulateFiles.php + ./src/Concerns/CanGenerateModulePanels.php + diff --git a/src/Activation/FileModuleActivator.php b/src/Activation/FileModuleActivator.php index 7ecf0ab..9b38149 100644 --- a/src/Activation/FileModuleActivator.php +++ b/src/Activation/FileModuleActivator.php @@ -30,8 +30,13 @@ public function isActive(ModuleDefinition | string $module, string | int | null } $name = $this->resolveName($module); + $nwidartModule = Module::find($name); - return Module::find($name)?->isEnabled() ?? false; + if ($nwidartModule === null) { + return false; + } + + return $nwidartModule->isEnabled(); } public function active(string | int | null $tenantId = null): Collection @@ -55,7 +60,11 @@ public function activate(ModuleDefinition | string $module, string | int | null $this->dependencyResolver->assertCanActivate($definition, $tenantId); foreach ($this->dependencyResolver->activationOrder($definition) as $moduleName) { - Module::find($moduleName)?->enable(); + $nwidartModule = Module::find($moduleName); + + if ($nwidartModule !== null) { + $nwidartModule->enable(); + } } } @@ -72,7 +81,11 @@ public function deactivate(ModuleDefinition | string $module, string | int | nul $this->dependencyResolver->assertCanDeactivate($definition, $tenantId); - Module::find($definition->name())?->disable(); + $nwidartModule = Module::find($definition->name()); + + if ($nwidartModule !== null) { + $nwidartModule->disable(); + } } protected function resolveDefinition(ModuleDefinition | string $module): ModuleDefinition diff --git a/src/ChartWidget.php b/src/ChartWidget.php index 8553ad4..5b281d9 100644 --- a/src/ChartWidget.php +++ b/src/ChartWidget.php @@ -2,9 +2,11 @@ namespace Coolsam\Modules; +use Coolsam\Modules\Traits\CanAccessTrait; + abstract class ChartWidget extends \Filament\Widgets\ChartWidget { - use \Coolsam\Modules\Traits\CanAccessTrait; + use CanAccessTrait; public static function canView(): bool { diff --git a/src/Commands/FileGenerators/ModulePanelProviderClassGenerator.php b/src/Commands/FileGenerators/ModulePanelProviderClassGenerator.php index 17bb99e..fb2b356 100644 --- a/src/Commands/FileGenerators/ModulePanelProviderClassGenerator.php +++ b/src/Commands/FileGenerators/ModulePanelProviderClassGenerator.php @@ -24,10 +24,11 @@ use Nette\PhpGenerator\ClassType; use Nette\PhpGenerator\Literal; use Nette\PhpGenerator\Method; +use Nwidart\Modules\Module; class ModulePanelProviderClassGenerator extends ClassGenerator { - public ?\Nwidart\Modules\Module $module; + public ?Module $module; final public function __construct( protected string $fqn, @@ -88,7 +89,7 @@ protected function addMethodsToClass(ClassType $class): void $this->addNavigationLabelMethodToClass($class); } - public function getModule(): \Nwidart\Modules\Module + public function getModule(): Module { return $this->module; } diff --git a/src/Commands/ModuleFilamentInstallCommand.php b/src/Commands/ModuleFilamentInstallCommand.php index 4219b64..aaadb96 100644 --- a/src/Commands/ModuleFilamentInstallCommand.php +++ b/src/Commands/ModuleFilamentInstallCommand.php @@ -7,7 +7,6 @@ use Illuminate\Console\Command; use Illuminate\Console\Concerns\PromptsForMissingInput; use Illuminate\Support\Facades\Config; -use Nwidart\Modules\Exceptions\ModuleNotFoundException; use Nwidart\Modules\Facades\Module; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; @@ -92,7 +91,7 @@ protected function getModule(): \Nwidart\Modules\Module { try { return Module::findOrFail($this->moduleName); - } catch (ModuleNotFoundException | \Throwable $exception) { + } catch (\Throwable $exception) { if (confirm("Module $this->moduleName does not exist. Would you like to generate it?", true)) { $this->call('module:make', ['name' => [$this->moduleName]]); diff --git a/src/Commands/ModuleMakeFilamentClusterCommand.php b/src/Commands/ModuleMakeFilamentClusterCommand.php index ec98945..6df981b 100644 --- a/src/Commands/ModuleMakeFilamentClusterCommand.php +++ b/src/Commands/ModuleMakeFilamentClusterCommand.php @@ -4,8 +4,10 @@ use Coolsam\Modules\Concerns\GeneratesModularFiles; use Coolsam\Modules\Facades\FilamentModules; +use Coolsam\Modules\Facades\ModuleRegistry; use Filament\Commands\MakeClusterCommand; use Illuminate\Support\Arr; +use Nwidart\Modules\Facades\Module; use function Laravel\Prompts\search; use function Laravel\Prompts\select; @@ -39,7 +41,7 @@ public function handle(): int public function ensureModuleArgument(): void { if (! $this->argument('module')) { - $module = select('Please select the module to create the cluster in:', \Coolsam\Modules\Facades\ModuleRegistry::all()->map(fn ($m) => $m->name())->all()); + $module = select('Please select the module to create the cluster in:', ModuleRegistry::all()->map(fn ($m) => $m->name())->all()); if (! $module) { $this->error('No module selected. Aborting cluster creation.'); exit(1); diff --git a/src/Commands/ModuleMakeFilamentPageCommand.php b/src/Commands/ModuleMakeFilamentPageCommand.php index a0edfe0..c1c8004 100644 --- a/src/Commands/ModuleMakeFilamentPageCommand.php +++ b/src/Commands/ModuleMakeFilamentPageCommand.php @@ -4,6 +4,7 @@ use Coolsam\Modules\Concerns\GeneratesModularFiles; use Coolsam\Modules\Facades\FilamentModules; +use Coolsam\Modules\Facades\ModuleRegistry; use Filament\Clusters\Cluster; use Filament\Commands\MakePageCommand; use Filament\Exceptions\NoDefaultPanelSetException; @@ -13,6 +14,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Str; use Illuminate\Support\Stringable; +use Nwidart\Modules\Facades\Module; use function Laravel\Prompts\confirm; use function Laravel\Prompts\search; @@ -52,7 +54,7 @@ public function handle(): int public function ensureModuleArgument(): void { if (! $this->argument('module')) { - $module = select('Please select the module to create the page in:', \Coolsam\Modules\Facades\ModuleRegistry::all()->map(fn ($m) => $m->name())->all()); + $module = select('Please select the module to create the page in:', ModuleRegistry::all()->map(fn ($m) => $m->name())->all()); if (! $module) { $this->error('No module selected. Aborting page creation.'); exit(1); diff --git a/src/Commands/ModuleMakeFilamentPanelCommand.php b/src/Commands/ModuleMakeFilamentPanelCommand.php index 06e8516..0226a7a 100644 --- a/src/Commands/ModuleMakeFilamentPanelCommand.php +++ b/src/Commands/ModuleMakeFilamentPanelCommand.php @@ -4,6 +4,7 @@ use Coolsam\Modules\Commands\FileGenerators\ModulePanelProviderClassGenerator; use Coolsam\Modules\Concerns\GeneratesModularFiles; +use Coolsam\Modules\Facades\ModuleRegistry; use Filament\Commands\MakePanelCommand; use Filament\Support\Commands\Concerns\CanGeneratePanels; use Filament\Support\Commands\Concerns\CanManipulateFiles; @@ -110,7 +111,7 @@ protected function ensureNavigationLabelOption(): void protected function ensureModuleArgument(): void { if (! $this->argument('module')) { - $module = select('Please select the module to create the panel in:', \Coolsam\Modules\Facades\ModuleRegistry::all()->map(fn ($m) => $m->name())->all()); + $module = select('Please select the module to create the panel in:', ModuleRegistry::all()->map(fn ($m) => $m->name())->all()); if (! $module) { $this->components->error('No module selected. Aborting panel creation.'); exit(1); diff --git a/src/Commands/ModuleMakeFilamentPluginCommand.php b/src/Commands/ModuleMakeFilamentPluginCommand.php index bd68765..b65164e 100644 --- a/src/Commands/ModuleMakeFilamentPluginCommand.php +++ b/src/Commands/ModuleMakeFilamentPluginCommand.php @@ -3,6 +3,7 @@ namespace Coolsam\Modules\Commands; use Coolsam\Modules\Concerns\GeneratesModularFiles; +use Coolsam\Modules\Facades\ModuleRegistry; use Illuminate\Console\GeneratorCommand; use function Laravel\Prompts\select; @@ -50,7 +51,7 @@ public function handle(): ?bool public function ensureModule() { if (! $this->argument('module')) { - $module = select('Please select the module to create the plugin in:', \Coolsam\Modules\Facades\ModuleRegistry::all()->map(fn ($m) => $m->name())->all()); + $module = select('Please select the module to create the plugin in:', ModuleRegistry::all()->map(fn ($m) => $m->name())->all()); $this->input->setArgument('module', $module); } } diff --git a/src/Commands/ModuleMakeFilamentThemeCommand.php b/src/Commands/ModuleMakeFilamentThemeCommand.php index 4efad19..d896543 100644 --- a/src/Commands/ModuleMakeFilamentThemeCommand.php +++ b/src/Commands/ModuleMakeFilamentThemeCommand.php @@ -18,6 +18,8 @@ class ModuleMakeFilamentThemeCommand extends MakeThemeCommand public function handle(Filesystem $filesystem): int { + $this->filesystem = $filesystem; + $module = $this->getModule(); $this->call('vendor:publish', [ diff --git a/src/Commands/ModuleMakeFilamentWidgetCommand.php b/src/Commands/ModuleMakeFilamentWidgetCommand.php index 542e169..d396618 100644 --- a/src/Commands/ModuleMakeFilamentWidgetCommand.php +++ b/src/Commands/ModuleMakeFilamentWidgetCommand.php @@ -4,12 +4,15 @@ use Coolsam\Modules\Concerns\GeneratesModularFiles; use Coolsam\Modules\Facades\FilamentModules; +use Coolsam\Modules\Facades\ModuleRegistry; use Filament\Panel; use Filament\Support\Facades\FilamentCli; use Filament\Widgets\Commands\MakeWidgetCommand; use Filament\Widgets\Widget; use Illuminate\Support\Arr; use Illuminate\Support\Str; +use Illuminate\Support\Stringable; +use Nwidart\Modules\Facades\Module; use function Laravel\Prompts\search; use function Laravel\Prompts\select; @@ -43,7 +46,7 @@ protected function getRelativeNamespace(): string public function ensureModule() { if (! $this->argument('module')) { - $module = select('Please select the module to create the page in:', \Coolsam\Modules\Facades\ModuleRegistry::all()->map(fn ($m) => $m->name())->all()); + $module = select('Please select the module to create the page in:', ModuleRegistry::all()->map(fn ($m) => $m->name())->all()); if (! $module) { $this->error('No module selected. Aborting page creation.'); exit(1); diff --git a/src/Concerns/GeneratesModularFiles.php b/src/Concerns/GeneratesModularFiles.php index 6988c0d..1c49f65 100644 --- a/src/Concerns/GeneratesModularFiles.php +++ b/src/Concerns/GeneratesModularFiles.php @@ -9,6 +9,12 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Finder\Finder; +/** + * @property string|null $type + * + * @method string getStub() + * @method static replaceNamespace(string $stub, string $name) + */ trait GeneratesModularFiles { use PromptsForMissingInput; @@ -22,7 +28,9 @@ protected function getArguments(): array protected function resolveStubPath($stub): string { - return FilamentModules::packagePath('Commands' . DIRECTORY_SEPARATOR . trim($stub, DIRECTORY_SEPARATOR)); + $stub = str($stub)->trim('/\\')->replace(['/', '\\'], DIRECTORY_SEPARATOR)->toString(); + + return FilamentModules::packagePath('src' . DIRECTORY_SEPARATOR . 'Commands' . DIRECTORY_SEPARATOR . $stub); } public function getModule(): Module @@ -48,7 +56,12 @@ protected function getPath($name): string $rootNamespace = str($this->rootNamespace())->trim('\\')->toString(); $name = Str::replaceFirst($rootNamespace, $appFolder, $name); - return $this->getModule()->getExtraPath(str_replace('\\', DIRECTORY_SEPARATOR, $name) . '.php'); + $path = $this->getModule()->getExtraPath(str_replace('\\', DIRECTORY_SEPARATOR, $name) . '.php'); + + return str($path) + ->replace(['/', '\\'], DIRECTORY_SEPARATOR) + ->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR) + ->toString(); } protected function possibleModels() diff --git a/src/Drivers/Nwidart/NwidartModuleRegistry.php b/src/Drivers/Nwidart/NwidartModuleRegistry.php index e0161d0..0b5e855 100644 --- a/src/Drivers/Nwidart/NwidartModuleRegistry.php +++ b/src/Drivers/Nwidart/NwidartModuleRegistry.php @@ -6,14 +6,22 @@ use Coolsam\Modules\Contracts\ModuleRegistry; use Illuminate\Support\Collection; use Nwidart\Modules\Facades\Module; +use Nwidart\Modules\Module as NwidartModule; class NwidartModuleRegistry implements ModuleRegistry { + /** + * @return Collection + */ public function all(): Collection { - return collect(Module::all()) - ->map(fn (Module $module) => new NwidartModuleDefinition($module)) - ->values(); + $definitions = []; + + foreach (Module::all() as $module) { + $definitions[] = $this->makeDefinition($module); + } + + return new Collection($definitions); } public function find(string $name): ?ModuleDefinition @@ -24,11 +32,16 @@ public function find(string $name): ?ModuleDefinition return null; } - return new NwidartModuleDefinition($module); + return $this->makeDefinition($module); } public function exists(string $name): bool { return Module::find($name) !== null; } + + protected function makeDefinition(NwidartModule $module): ModuleDefinition + { + return new NwidartModuleDefinition($module); + } } diff --git a/src/Facades/FilamentModules.php b/src/Facades/FilamentModules.php index e629ab2..68932de 100644 --- a/src/Facades/FilamentModules.php +++ b/src/Facades/FilamentModules.php @@ -2,15 +2,16 @@ namespace Coolsam\Modules\Facades; +use Coolsam\Modules\Modules; use Illuminate\Support\Facades\Facade; /** - * @see \Coolsam\Modules\Modules + * @see Modules */ class FilamentModules extends Facade { protected static function getFacadeAccessor() { - return \Coolsam\Modules\Modules::class; + return Modules::class; } } diff --git a/src/Modules.php b/src/Modules.php index efc12eb..be08ab9 100644 --- a/src/Modules.php +++ b/src/Modules.php @@ -67,14 +67,20 @@ public function getModuleClusters(string $moduleName) public function convertPathToNamespace(string $fullPath): string { + $normalizedPath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $fullPath); $appFolder = trim(config('modules.paths.app_folder', 'app'), '/\\'); - $appPath = $appFolder . DIRECTORY_SEPARATOR; - $base = str(trim(config('modules.paths.modules', base_path('Modules')), '/\\')); - $replacementPath = str_replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, '/', DIRECTORY_SEPARATOR . $appPath); - $relative = str($fullPath)->afterLast($base)->replaceFirst($replacementPath, DIRECTORY_SEPARATOR); + $base = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, trim(config('modules.paths.modules', base_path('Modules')), '/\\')); + $appSegment = $appFolder . DIRECTORY_SEPARATOR; + + $relative = str($normalizedPath)->afterLast($base)->ltrim(DIRECTORY_SEPARATOR); + + if (str($relative)->startsWith($appSegment)) { + $relative = str($relative)->after($appSegment); + } else { + $relative = str($relative)->replace(DIRECTORY_SEPARATOR . $appSegment, DIRECTORY_SEPARATOR); + } return str($relative) - ->ltrim('/\\') ->prepend(DIRECTORY_SEPARATOR) ->prepend(config('modules.namespace', 'Modules')) ->replace(DIRECTORY_SEPARATOR, '\\') @@ -85,6 +91,54 @@ public function convertPathToNamespace(string $fullPath): string ->implode('\\'); } + public function findModuleNameForPath(string $path): ?string + { + $normalizedPath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path); + $modulesPath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, config('modules.paths.modules', base_path('Modules'))); + + $directory = is_file($normalizedPath) ? dirname($normalizedPath) : $normalizedPath; + + while (str($directory)->startsWith($modulesPath) && $directory !== $modulesPath) { + $moduleJsonPath = $directory . DIRECTORY_SEPARATOR . 'module.json'; + + if (is_file($moduleJsonPath)) { + $moduleJson = json_decode((string) file_get_contents($moduleJsonPath), true); + + return is_array($moduleJson) ? ($moduleJson['name'] ?? basename($directory)) : basename($directory); + } + + $parentDirectory = dirname($directory); + + if ($parentDirectory === $directory) { + break; + } + + $directory = $parentDirectory; + } + + return null; + } + + public function resolveClassFromProviderFile(string $providerPath): ?string + { + if (! is_file($providerPath)) { + return null; + } + + $content = file_get_contents($providerPath); + + if ($content === false || ! preg_match('/^namespace\s+([^;]+);/m', $content, $matches)) { + return null; + } + + return trim($matches[1]) . '\\' . basename($providerPath, '.php'); + } + + public function resolveProviderClass(string $providerPath): string + { + return $this->resolveClassFromProviderFile($providerPath) ?? $this->convertPathToNamespace($providerPath); + } + public function execCommand(string $command, ?Command $artisan = null): void { $process = Process::fromShellCommandline($command); @@ -101,7 +155,7 @@ public function execCommand(string $command, ?Command $artisan = null): void public function packagePath(string $path = ''): string { // return the base path of this package - return dirname(__DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR) . ($path ? DIRECTORY_SEPARATOR . trim($path, DIRECTORY_SEPARATOR) : ''); + return dirname(__DIR__) . ($path ? DIRECTORY_SEPARATOR . trim($path, DIRECTORY_SEPARATOR) : ''); } public function getMode(): ?ConfigMode diff --git a/src/ModulesPlugin.php b/src/ModulesPlugin.php index d01075f..8e4c82e 100644 --- a/src/ModulesPlugin.php +++ b/src/ModulesPlugin.php @@ -8,6 +8,7 @@ use Filament\Navigation\NavigationGroup; use Filament\Navigation\NavigationItem; use Filament\Panel; +use Filament\Support\Icons\Heroicon; use Nwidart\Modules\Facades\Module as ModuleFacade; class ModulesPlugin implements Plugin @@ -36,7 +37,7 @@ public function boot(Panel $panel): void $mode = ConfigMode::tryFrom(config('filament-modules.mode', ConfigMode::BOTH->value)); if ($mode?->shouldRegisterPanels()) { $group = config('filament-modules.panels.group', 'Modules'); - $groupIcon = config('filament-modules.panels.group-icon', \Filament\Support\Icons\Heroicon::OutlinedRectangleStack); + $groupIcon = config('filament-modules.panels.group-icon', Heroicon::OutlinedRectangleStack); $groupSort = config('filament-modules.panels.group-sort', 0); $openInNewTab = config('filament-modules.panels.open-in-new-tab', false); @@ -54,7 +55,7 @@ public function boot(Panel $panel): void } // $panelLabel = str($panel->getId())->after($moduleName)->trim('-')->snake()->title()->replace('_', ' '); // $label = str($module->getTitle())->append(" - ")->append($panelLabel); - $label = $panel->getBrandName() ?? str($panel->getId())->after($moduleName)->trim('-')->studly()->snake()->replace('_', ' ')->toString(); + $label = $panel->getBrandName() ?: str($panel->getId())->after($moduleName)->trim('-')->studly()->snake()->replace('_', ' ')->toString(); return NavigationItem::make($label) ->group($group) @@ -108,16 +109,22 @@ protected function getModulePanels(): array $pattern = $basePath . DIRECTORY_SEPARATOR . '*' . DIRECTORY_SEPARATOR . $appFolder . DIRECTORY_SEPARATOR . 'Providers' . DIRECTORY_SEPARATOR . 'Filament' . DIRECTORY_SEPARATOR . '*.php'; $panelPaths = glob($pattern); - $panelIds = collect($panelPaths)->map(fn ($path) => FilamentModules::convertPathToNamespace($path))->map(function ($class) { - // Get the panel ID and check if it is registered - $id = str($class)->afterLast('\\')->before('PanelProvider')->kebab()->lower(); - // get module it belongs to as well - $moduleName = str($class)->after('Modules\\')->before('\\Providers\\Filament'); - $module = ModuleFacade::find($moduleName); + $panelIds = collect($panelPaths)->map(function ($path) { + $class = FilamentModules::resolveProviderClass($path); + + if (! class_exists($class)) { + return null; + } + + $moduleName = FilamentModules::findModuleNameForPath($path); + $module = $moduleName ? ModuleFacade::find($moduleName) : null; + if (! $module) { return null; } + $id = str($class)->afterLast('\\')->before('PanelProvider')->kebab()->lower(); + return str($id)->prepend('-')->prepend($module->getKebabName()); }); diff --git a/src/ModulesServiceProvider.php b/src/ModulesServiceProvider.php index 238ec0f..ee0c279 100644 --- a/src/ModulesServiceProvider.php +++ b/src/ModulesServiceProvider.php @@ -2,7 +2,16 @@ namespace Coolsam\Modules; +use Coolsam\Modules\Activation\FileModuleActivator; +use Coolsam\Modules\Contracts\DependencyResolver; +use Coolsam\Modules\Contracts\ModuleActivator; +use Coolsam\Modules\Contracts\ModuleDefinition; +use Coolsam\Modules\Contracts\ModuleRegistry; +use Coolsam\Modules\Contracts\TenantContext; +use Coolsam\Modules\Dependencies\ModuleDependencyResolver; +use Coolsam\Modules\Drivers\Nwidart\NwidartModuleRegistry; use Coolsam\Modules\Facades\FilamentModules; +use Coolsam\Modules\Support\DefaultTenantContext; use Coolsam\Modules\Testing\TestsModules; use Filament\Support\Assets\Asset; use Filament\Support\Facades\FilamentAsset; @@ -34,7 +43,7 @@ public function configurePackage(Package $package): void $command ->publishConfigFile() ->endWith(function (InstallCommand $command) { - $command->askToStarRepoOnGitHub('savannabits/filament-modules'); + $command->askToStarRepoOnGitHub('coolsam726/filament-modules'); }); }); @@ -66,23 +75,23 @@ public function packageRegistered(): void protected function registerModuleRuntime(): void { - $this->app->singleton(\Coolsam\Modules\Contracts\ModuleRegistry::class, \Coolsam\Modules\Drivers\Nwidart\NwidartModuleRegistry::class); + $this->app->singleton(ModuleRegistry::class, NwidartModuleRegistry::class); - $this->app->singleton(\Coolsam\Modules\Contracts\TenantContext::class, function ($app) { + $this->app->singleton(TenantContext::class, function ($app) { $class = config('filament-modules.tenancy.context'); if (is_string($class) && class_exists($class)) { return $app->make($class); } - return $app->make(\Coolsam\Modules\Support\DefaultTenantContext::class); + return $app->make(DefaultTenantContext::class); }); - $this->app->singleton(\Coolsam\Modules\Contracts\DependencyResolver::class, \Coolsam\Modules\Dependencies\ModuleDependencyResolver::class); + $this->app->singleton(DependencyResolver::class, ModuleDependencyResolver::class); - $this->app->singleton(\Coolsam\Modules\Contracts\ModuleActivator::class, function ($app) { + $this->app->singleton(ModuleActivator::class, function ($app) { return match (config('filament-modules.activation.driver', 'file')) { - 'file' => $app->make(\Coolsam\Modules\Activation\FileModuleActivator::class), + 'file' => $app->make(FileModuleActivator::class), default => throw new \InvalidArgumentException( 'Unsupported module activation driver [' . config('filament-modules.activation.driver') . '].' ), @@ -106,11 +115,17 @@ public function attemptToRegisterModuleProviders(): void $providers = array_merge($serviceProviders, $panelProviders); foreach ($providers as $provider) { - $namespace = FilamentModules::convertPathToNamespace($provider); - $module = str($namespace)->before('\Providers\\')->afterLast('\\')->toString(); + $namespace = FilamentModules::resolveProviderClass($provider); + $moduleName = FilamentModules::findModuleNameForPath($provider); + + if (! $moduleName || ! app(ModuleActivator::class)->isActive($moduleName)) { + continue; + } + $className = str($namespace)->afterLast('\\')->toString(); - if (str($className)->startsWith($module) && app(\Coolsam\Modules\Contracts\ModuleActivator::class)->isActive($module)) { - // register the module service provider + $moduleStudlyName = str($moduleName)->studly()->toString(); + + if (str($className)->startsWith($moduleStudlyName) && class_exists($namespace)) { $this->app->register($namespace); } } @@ -119,11 +134,11 @@ public function attemptToRegisterModuleProviders(): void public function autoDiscoverPanels(): void { $this->app->beforeResolving('filament', function () { - $activator = app(\Coolsam\Modules\Contracts\ModuleActivator::class); - $panels = app(\Coolsam\Modules\Contracts\ModuleRegistry::class) + $activator = app(ModuleActivator::class); + $panels = app(ModuleRegistry::class) ->all() - ->filter(fn (\Coolsam\Modules\Contracts\ModuleDefinition $module) => $activator->isActive($module)) - ->flatMap(function (\Coolsam\Modules\Contracts\ModuleDefinition $moduleDefinition) { + ->filter(fn (ModuleDefinition $module) => $activator->isActive($module)) + ->flatMap(function (ModuleDefinition $moduleDefinition) { $module = ModuleFacade::find($moduleDefinition->name()); if (! $module) { @@ -266,39 +281,55 @@ protected function registerModuleMacros(): void NwidartModule::macro('appPath', function (string $relativePath = '') { $appPath = $this->getExtraPath(config('modules.paths.app_folder', 'app')); - return str($appPath . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : ''))->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR)->toString(); + return str($appPath . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : '')) + ->replace(['/', '\\'], DIRECTORY_SEPARATOR) + ->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR) + ->toString(); }); NwidartModule::macro('databasePath', function (string $relativePath = '') { $appPath = $this->getExtraPath('database'); - return str($appPath . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : ''))->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR)->toString(); + return str($appPath . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : '')) + ->replace(['/', '\\'], DIRECTORY_SEPARATOR) + ->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR) + ->toString(); }); NwidartModule::macro('resourcesPath', function (string $relativePath = '') { $appPath = $this->getExtraPath('resources'); return str($appPath . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : '')) - ->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR)->toString(); + ->replace(['/', '\\'], DIRECTORY_SEPARATOR) + ->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR) + ->toString(); }); NwidartModule::macro('migrationsPath', function (string $relativePath = '') { $appPath = $this->databasePath('migrations'); return str($appPath . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : '')) - ->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR)->toString(); + ->replace(['/', '\\'], DIRECTORY_SEPARATOR) + ->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR) + ->toString(); }); NwidartModule::macro('seedersPath', function (string $relativePath = '') { $appPath = $this->databasePath('seeders'); - return str($appPath . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : ''))->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR)->toString(); + return str($appPath . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : '')) + ->replace(['/', '\\'], DIRECTORY_SEPARATOR) + ->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR) + ->toString(); }); NwidartModule::macro('factoriesPath', function (string $relativePath = '') { $appPath = $this->databasePath('factories'); - return str($appPath . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : ''))->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR)->toString(); + return str($appPath . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : '')) + ->replace(['/', '\\'], DIRECTORY_SEPARATOR) + ->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR) + ->toString(); }); } } diff --git a/src/Page.php b/src/Page.php index e003caf..b3b7226 100644 --- a/src/Page.php +++ b/src/Page.php @@ -2,7 +2,9 @@ namespace Coolsam\Modules; +use Coolsam\Modules\Traits\CanAccessTrait; + abstract class Page extends \Filament\Pages\Page { - use \Coolsam\Modules\Traits\CanAccessTrait; + use CanAccessTrait; } diff --git a/src/Resource.php b/src/Resource.php index 12c1676..4372624 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -2,7 +2,10 @@ namespace Coolsam\Modules; -abstract class Resource extends \Filament\Resources\Resource +use Coolsam\Modules\Traits\CanAccessTrait; +use Filament\Resources\Resource as FilamentResource; + +abstract class Resource extends FilamentResource { - use \Coolsam\Modules\Traits\CanAccessTrait; + use CanAccessTrait; } diff --git a/src/StatsOverviewWidget.php b/src/StatsOverviewWidget.php index ce07f30..f42b151 100644 --- a/src/StatsOverviewWidget.php +++ b/src/StatsOverviewWidget.php @@ -2,9 +2,11 @@ namespace Coolsam\Modules; +use Coolsam\Modules\Traits\CanAccessTrait; + abstract class StatsOverviewWidget extends \Filament\Widgets\StatsOverviewWidget { - use \Coolsam\Modules\Traits\CanAccessTrait; + use CanAccessTrait; public static function canView(): bool { diff --git a/src/TableWidget.php b/src/TableWidget.php index d08fc41..f7c053a 100644 --- a/src/TableWidget.php +++ b/src/TableWidget.php @@ -2,9 +2,11 @@ namespace Coolsam\Modules; +use Coolsam\Modules\Traits\CanAccessTrait; + abstract class TableWidget extends \Filament\Widgets\TableWidget { - use \Coolsam\Modules\Traits\CanAccessTrait; + use CanAccessTrait; public static function canView(): bool { diff --git a/src/Traits/CanAccessTrait.php b/src/Traits/CanAccessTrait.php index dbb4e55..68a8490 100644 --- a/src/Traits/CanAccessTrait.php +++ b/src/Traits/CanAccessTrait.php @@ -2,6 +2,8 @@ namespace Coolsam\Modules\Traits; +use Coolsam\Modules\Contracts\ModuleActivator; + trait CanAccessTrait { public static function getCurrentModuleName(): string @@ -15,10 +17,19 @@ public static function getCurrentModuleName(): string public static function canAccess(): bool { - $isModuleEnabled = app(\Coolsam\Modules\Contracts\ModuleActivator::class)->isActive( + $isModuleEnabled = app(ModuleActivator::class)->isActive( static::getCurrentModuleName() ); - $parentAccess = function_exists('canAccess') ? parent::canAccess() : true; + $parentClass = get_parent_class(static::class); + $parentAccess = true; + + if ( + is_string($parentClass) + && str_starts_with($parentClass, 'Filament\\') + && method_exists($parentClass, 'canAccess') + ) { + $parentAccess = $parentClass::canAccess(); + } if ($isModuleEnabled && $parentAccess) { return true; diff --git a/tests/Support/CreatesTestModules.php b/tests/Support/CreatesTestModules.php new file mode 100644 index 0000000..ed8cdeb --- /dev/null +++ b/tests/Support/CreatesTestModules.php @@ -0,0 +1,201 @@ +workbenchPath('Modules'); + } + + protected function resetModulesDirectory(): void + { + $modulesPath = $this->modulesPath(); + + if (is_dir($modulesPath)) { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($modulesPath, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + } else { + mkdir($modulesPath, 0755, true); + } + + $statusesFile = $this->workbenchPath('modules_statuses.json'); + + if (file_exists($statusesFile)) { + unlink($statusesFile); + } + + $this->clearModuleRepositoryCache(); + } + + protected function createTestModule(string $name = 'Blog', bool $enabled = true): LaravelModule + { + $modulePath = $this->modulesPath() . DIRECTORY_SEPARATOR . $name; + + if (! is_dir($modulePath)) { + mkdir($modulePath . DIRECTORY_SEPARATOR . 'app', 0755, true); + file_put_contents($modulePath . DIRECTORY_SEPARATOR . 'module.json', json_encode([ + 'name' => $name, + 'alias' => strtolower($name), + 'description' => '', + 'keywords' => [], + 'priority' => 0, + 'providers' => [], + 'files' => [], + ], JSON_THROW_ON_ERROR)); + } + + $this->clearModuleRepositoryCache(); + + $module = Module::findOrFail($name); + + $enabled ? $module->enable() : $module->disable(); + + return $module; + } + + protected function createModuleModel(string $moduleName = 'Blog', string $modelName = 'Post'): void + { + $module = $this->createTestModule($moduleName); + $modelsPath = $module->appPath('Models'); + + if (! is_dir($modelsPath)) { + mkdir($modelsPath, 0755, true); + } + + file_put_contents($modelsPath . DIRECTORY_SEPARATOR . $modelName . '.php', <<<'PHP' +createTestModule($moduleName); + $clusterDir = $module->appPath('Filament' . DIRECTORY_SEPARATOR . 'Clusters' . DIRECTORY_SEPARATOR . $clusterName); + + if (! is_dir($clusterDir)) { + mkdir($clusterDir, 0755, true); + } + + file_put_contents($clusterDir . DIRECTORY_SEPARATOR . $clusterName . 'Cluster.php', <<createTestModule($moduleName); + $pluginDir = $module->appPath('Filament'); + + if (! is_dir($pluginDir)) { + mkdir($pluginDir, 0755, true); + } + + file_put_contents($pluginDir . DIRECTORY_SEPARATOR . $pluginName, <<<'PHP' +createTestModule($moduleName); + $providerDir = $module->appPath('Providers' . DIRECTORY_SEPARATOR . 'Filament'); + + if (! is_dir($providerDir)) { + mkdir($providerDir, 0755, true); + } + + $namespace ??= "Modules\\{$moduleName}\\Providers\\Filament"; + $panelId = $module->getLowerName() . '-admin'; + $panelPath = $module->getLowerName() . '/admin'; + + file_put_contents($providerDir . DIRECTORY_SEPARATOR . $providerClass . '.php', <<id('{$panelId}') + ->path('{$panelPath}'); + } +} +PHP); + + $providerPath = $providerDir . DIRECTORY_SEPARATOR . $providerClass . '.php'; + + if (! class_exists("{$namespace}\\{$providerClass}", false)) { + require_once $providerPath; + } + + return $module; + } + + protected function registerTestPanel(Panel $panel): Panel + { + filament()->registerPanel($panel); + + return $panel; + } + + protected function clearModuleRepositoryCache(): void + { + $reflection = new \ReflectionClass(FileRepository::class); + + if ($reflection->hasProperty('modules')) { + $property = $reflection->getProperty('modules'); + $property->setAccessible(true); + $property->setValue(null, []); + } + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 5f8eb9f..f42cc7a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,6 +5,7 @@ use BladeUI\Heroicons\BladeHeroiconsServiceProvider; use BladeUI\Icons\BladeIconsServiceProvider; use Coolsam\Modules\ModulesServiceProvider; +use Coolsam\Modules\Tests\Support\CreatesTestModules; use Filament\Actions\ActionsServiceProvider; use Filament\FilamentServiceProvider; use Filament\Forms\FormsServiceProvider; @@ -22,10 +23,13 @@ class TestCase extends Orchestra { + use CreatesTestModules; use WithWorkbench; protected function setUp(): void { + $this->resetModulesDirectory(); + parent::setUp(); Factory::guessFactoryNamesUsing( @@ -57,9 +61,15 @@ public function getEnvironmentSetUp($app) { config()->set('database.default', 'testing'); - /* - $migration = include __DIR__.'/../database/migrations/create_modules_table.php.stub'; - $migration->up(); - */ + $modulesPath = $app->basePath('Modules'); + + if (! is_dir($modulesPath)) { + mkdir($modulesPath, 0755, true); + } + + config()->set('modules.paths.modules', $modulesPath); + config()->set('modules.namespace', 'Modules'); + config()->set('modules.paths.app_folder', 'app'); + config()->set('modules.activators.file.statuses-file', $app->basePath('modules_statuses.json')); } } diff --git a/tests/Unit/CanAccessTraitTest.php b/tests/Unit/CanAccessTraitTest.php new file mode 100644 index 0000000..0e67dab --- /dev/null +++ b/tests/Unit/CanAccessTraitTest.php @@ -0,0 +1,45 @@ +createTestModule('Blog', enabled: true); + + expect(TestChartWidget::canAccess())->toBeTrue(); + expect(TestChartWidget::canView())->toBeTrue(); +}); + +test('can access trait denies widget access when module is disabled', function () { + $this->createTestModule('Blog', enabled: false); + + expect(TestChartWidget::canAccess())->toBeFalse(); + expect(TestChartWidget::canView())->toBeFalse(); +}); + +test('can access trait does not call parent can access when parent lacks the method', function () { + $this->createTestModule('Blog', enabled: true); + + expect(method_exists(ChartWidget::class, 'canAccess'))->toBeTrue(); + expect(method_exists(Filament\Widgets\ChartWidget::class, 'canAccess'))->toBeFalse(); +}); + +test('can access trait resolves the module name from the class namespace', function () { + expect(TestChartWidget::getCurrentModuleName())->toBe('blog'); +}); diff --git a/tests/Unit/ConfigModeTest.php b/tests/Unit/ConfigModeTest.php new file mode 100644 index 0000000..4186d09 --- /dev/null +++ b/tests/Unit/ConfigModeTest.php @@ -0,0 +1,21 @@ +shouldRegisterPanels())->toBeTrue(); + expect(ConfigMode::PANELS->shouldRegisterPlugins())->toBeFalse(); + + expect(ConfigMode::PLUGINS->shouldRegisterPanels())->toBeFalse(); + expect(ConfigMode::PLUGINS->shouldRegisterPlugins())->toBeTrue(); + + expect(ConfigMode::BOTH->shouldRegisterPanels())->toBeTrue(); + expect(ConfigMode::BOTH->shouldRegisterPlugins())->toBeTrue(); +}); + +test('config mode can be resolved from config values', function () { + config()->set('filament-modules.mode', ConfigMode::PLUGINS->value); + + expect(FilamentModules::getMode())->toBe(ConfigMode::PLUGINS); +}); diff --git a/tests/Unit/GeneratesModularFilesConcernTest.php b/tests/Unit/GeneratesModularFilesConcernTest.php index 15beddb..8c9c681 100644 --- a/tests/Unit/GeneratesModularFilesConcernTest.php +++ b/tests/Unit/GeneratesModularFilesConcernTest.php @@ -1,28 +1,295 @@ trait = new class extends \Illuminate\Console\Command + $this->command = new class(app(Filesystem::class)) extends GeneratorCommand { - use Coolsam\Modules\Concerns\GeneratesModularFiles; + use GeneratesModularFiles; + + protected $name = 'test:make-modular'; + + protected $description = 'Test modular generator'; + + protected $type = 'Filament Plugin'; public function getRelativeNamespace(): string { - return 'Commands'; + return 'Filament\\Resources'; + } + + protected function getStub(): string + { + return $this->resolveStubPath('stubs/filament-plugin.stub'); + } + + public function exposeGetStub(): string + { + return $this->getStub(); + } + + protected function stubReplacements(): array + { + return [ + 'moduleStudlyName' => $this->getModule()->getStudlyName(), + 'pluginId' => 'blog', + ]; + } + + public function exposeRootNamespace(): string + { + return $this->rootNamespace(); + } + + public function exposeDefaultNamespace(string $rootNamespace): string + { + return $this->getDefaultNamespace($rootNamespace); } - public function getStub() + public function exposeViewPath(string $path = ''): string { - $d = DIRECTORY_SEPARATOR; + return $this->viewPath($path); + } + + public function exposePath(string $name): string + { + return $this->getPath($name); + } - return $this->resolveStubPath("stubs{$d}filament-plugin.stub"); + public function exposePossibleModels(): array + { + return $this->possibleModels(); + } + + public function exposeBuildClass(string $name): string + { + return $this->buildClass($name); + } + + public function exposePrompts(): array + { + return $this->promptForMissingArgumentsUsing(); + } + + public function exposeArguments(): array + { + return $this->getArguments(); } }; + + $this->command->setLaravel($this->app); + + $input = Mockery::mock(InputInterface::class); + $input->shouldReceive('getArgument')->with('module')->andReturn('Blog'); + $input->shouldReceive('getArgument')->with('name')->andReturn('PostResource'); + $this->command->setInput($input); }); test('can generate the correct stubs path', function () { - // include the GeneratesModularFiles trait - $d = DIRECTORY_SEPARATOR; - expect($this->trait->getStub()) - ->toEqual(realpath(__DIR__ . "{$d}..{$d}..{$d}src{$d}Commands{$d}stubs{$d}filament-plugin.stub")); + $expected = realpath(dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'Commands' . DIRECTORY_SEPARATOR . 'stubs' . DIRECTORY_SEPARATOR . 'filament-plugin.stub'); + + expect(realpath($this->command->exposeGetStub()))->toEqual($expected); +}); + +test('modular generator resolves module namespace and paths', function () { + $this->createTestModule('Blog'); + + expect($this->command->getModule()->getName())->toBe('Blog'); + expect($this->command->exposeRootNamespace())->toBe('Modules\\Blog\\'); + expect($this->command->exposeDefaultNamespace('Modules\\Blog\\')) + ->toBe('Modules\\Blog\\Filament\\Resources'); + expect($this->command->exposeViewPath('pages'))->toEndWith('resources' . DIRECTORY_SEPARATOR . 'views' . DIRECTORY_SEPARATOR . 'pages'); + expect(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $this->command->exposePath('Modules\\Blog\\Filament\\Resources\\PostResource'))) + ->toEndWith('Blog' . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'Filament' . DIRECTORY_SEPARATOR . 'Resources' . DIRECTORY_SEPARATOR . 'PostResource.php'); +}); + +test('modular generator can list possible models', function () { + $this->createModuleModel('Blog', 'Post'); + + expect($this->command->exposePossibleModels())->toBe(['Post']); + expect($this->command->possibleFqnModels())->toBe(['Modules\\Blog\\Models\\Post']); +}); + +test('modular generator builds class content from stub replacements', function () { + $this->createTestModule('Blog'); + + $input = Mockery::mock(InputInterface::class); + $input->shouldReceive('getArgument')->with('module')->andReturn('Blog'); + $input->shouldReceive('getArgument')->with('name')->andReturn('AccessPlugin'); + $this->command->setInput($input); + + $class = $this->command->exposeBuildClass('Modules\\Blog\\Filament\\AccessPlugin'); + + expect($class)->toContain('AccessPlugin'); + expect($class)->toContain('Blog'); +}); + +test('modular generator exposes prompt metadata for missing arguments', function () { + $prompts = $this->command->exposePrompts(); + + expect($prompts)->toHaveKeys(['name', 'module']); + expect($prompts['name'][0])->toContain('filament plugin'); +}); + +test('modular generator merges module argument into command definition', function () { + $arguments = $this->command->exposeArguments(); + + expect(collect($arguments)->pluck(0))->toContain('module'); +}); + +test('modular generator exposes default stub replacements', function () { + $command = new class(app(Filesystem::class)) extends GeneratorCommand + { + use GeneratesModularFiles; + + protected $name = 'test:default-replacements'; + + protected $description = 'Test default stub replacements'; + + protected $type = 'Filament Plugin'; + + protected function getRelativeNamespace(): string + { + return 'Filament'; + } + + protected function getStub(): string + { + return ''; + } + + public function exposeStubReplacements(): array + { + return $this->stubReplacements(); + } + + public function exposePromptForType(?string $type): array + { + $this->type = $type; + + return $this->promptForMissingArgumentsUsing()['name']; + } + }; + + expect($command->exposeStubReplacements())->toBe([]); + expect($command->exposePromptForType('Model')[1])->toBe('E.g. Flight'); + expect($command->exposePromptForType('Unknown')[1])->toBe(''); + expect($command->exposePromptForType(null)[0])->toContain('class'); +}); + +test('modular generator exposes type-specific prompt hints', function (string $type, string $expectedHint) { + $command = new class(app(Filesystem::class)) extends GeneratorCommand + { + use GeneratesModularFiles; + + protected $name = 'test:prompt-hints'; + + protected $description = 'Test prompt hints'; + + protected $type = ''; + + protected function getRelativeNamespace(): string + { + return 'Filament'; + } + + protected function getStub(): string + { + return ''; + } + + public function exposePromptHintForType(string $type): string + { + $this->type = $type; + + return $this->promptForMissingArgumentsUsing()['name'][1]; + } + }; + + expect($command->exposePromptHintForType($type))->toBe($expectedHint); +})->with([ + ['Cast', 'E.g. Json'], + ['Channel', 'E.g. OrderChannel'], + ['Console command', 'E.g. SendEmails'], + ['Component', 'E.g. Alert'], + ['Controller', 'E.g. UserController'], + ['Event', 'E.g. PodcastProcessed'], + ['Exception', 'E.g. InvalidOrderException'], + ['Factory', 'E.g. PostFactory'], + ['Job', 'E.g. ProcessPodcast'], + ['Listener', 'E.g. SendPodcastNotification'], + ['Mailable', 'E.g. OrderShipped'], + ['Middleware', 'E.g. EnsureTokenIsValid'], + ['Notification', 'E.g. InvoicePaid'], + ['Observer', 'E.g. UserObserver'], + ['Policy', 'E.g. PostPolicy'], + ['Provider', 'E.g. ElasticServiceProvider'], + ['Request', 'E.g. StorePodcastRequest'], + ['Resource', 'E.g. UserResource'], + ['Rule', 'E.g. Uppercase'], + ['Scope', 'E.g. TrendingScope'], + ['Seeder', 'E.g. UserSeeder'], + ['Test', 'E.g. UserTest'], + ['Filament Cluster', 'E.g Settings'], + ['Filament Plugin', 'e.g AccessControlPlugin'], +]); + +test('modular generator applies stub replacements in both placeholder formats', function () { + $command = new class(app(Filesystem::class)) extends GeneratorCommand + { + use GeneratesModularFiles; + + protected $name = 'test:stub-replacements'; + + protected $description = 'Test stub replacements'; + + protected $type = 'Filament Plugin'; + + protected function getRelativeNamespace(): string + { + return 'Filament'; + } + + protected function getStub(): string + { + return ''; + } + + protected function stubReplacements(): array + { + return [ + 'token' => 'replaced', + ]; + } + + public function exposeApplyStubReplacements(string $stub): string + { + $this->applyStubReplacements($stub); + + return $stub; + } + }; + + expect($command->exposeApplyStubReplacements('{{ token }} and {{token}}')) + ->toBe('replaced and replaced'); +}); + +test('modular generator resolves view path without a subpath', function () { + $this->createTestModule('Blog'); + + expect($this->command->exposeViewPath()) + ->toEndWith('resources' . DIRECTORY_SEPARATOR . 'views'); + expect($this->command->exposeViewPath()) + ->not->toContain('views' . DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR); +}); + +test('modular generator includes module prompt metadata', function () { + $prompts = $this->command->exposePrompts(); + + expect($prompts['module'][0])->toBe('In which Module should we create this?'); + expect($prompts['module'][1])->toBe('e.g Blog'); + expect($prompts['module'][2])->toBeTrue(); }); diff --git a/tests/Unit/ModularWidgetTest.php b/tests/Unit/ModularWidgetTest.php new file mode 100644 index 0000000..662c230 --- /dev/null +++ b/tests/Unit/ModularWidgetTest.php @@ -0,0 +1,93 @@ +createTestModule('Blog', enabled: true); + + expect(TestStatsWidget::canAccess())->toBeTrue(); + expect(TestStatsWidget::canView())->toBeTrue(); +}); + +test('table widget delegates can view to can access', function () { + $this->createTestModule('Blog', enabled: true); + + expect(TestTableWidget::canAccess())->toBeTrue(); + expect(TestTableWidget::canView())->toBeTrue(); +}); + +test('can access trait respects filament parent access checks', function () { + $this->createTestModule('Blog', enabled: true); + + expect(ParentAccessWidget::canAccess())->toBeFalse(); + expect(ParentAccessWidget::canView())->toBeFalse(); +}); + +test('base modular widgets extend filament widgets', function () { + expect(is_subclass_of(StatsOverviewWidget::class, Filament\Widgets\StatsOverviewWidget::class))->toBeTrue(); + expect(is_subclass_of(TableWidget::class, Filament\Widgets\TableWidget::class))->toBeTrue(); +}); diff --git a/tests/Unit/ModuleFilamentPluginTest.php b/tests/Unit/ModuleFilamentPluginTest.php new file mode 100644 index 0000000..5e05119 --- /dev/null +++ b/tests/Unit/ModuleFilamentPluginTest.php @@ -0,0 +1,76 @@ +createTestModule('Blog', enabled: false); + + $plugin = new class + { + use ModuleFilamentPlugin; + + public function getModuleName(): string + { + return 'Blog'; + } + + public function getId(): string + { + return 'blog-module-plugin'; + } + }; + + $panel = Panel::make()->id('admin')->path('admin'); + $plugin->register($panel); + + expect(Module::isEnabled('Blog'))->toBeFalse(); +}); + +test('module filament plugin registers discovery paths when module is enabled', function () { + config()->set('filament-modules.clusters.enabled', true); + + $module = $this->createTestModule('Blog', enabled: true); + + foreach ([ + 'Filament/Pages', + 'Filament/Resources', + 'Filament/Widgets', + 'Livewire', + 'Filament/Clusters/Settings', + ] as $relativePath) { + $path = $module->appPath(str_replace('/', DIRECTORY_SEPARATOR, $relativePath)); + + if (! is_dir($path)) { + mkdir($path, 0755, true); + } + } + + $plugin = new class + { + use ModuleFilamentPlugin; + + public bool $afterRegisterCalled = false; + + public function getModuleName(): string + { + return 'Blog'; + } + + public function getId(): string + { + return 'blog-module-plugin'; + } + + public function afterRegister(Panel $panel): void + { + $this->afterRegisterCalled = true; + } + }; + + $panel = Panel::make()->id('admin')->path('admin'); + $plugin->register($panel); + + expect($plugin->afterRegisterCalled)->toBeTrue(); +}); diff --git a/tests/Unit/ModuleMacrosTest.php b/tests/Unit/ModuleMacrosTest.php new file mode 100644 index 0000000..44e408b --- /dev/null +++ b/tests/Unit/ModuleMacrosTest.php @@ -0,0 +1,32 @@ +createTestModule('Blog'); + + expect($module->namespace(''))->toBe('Modules\\Blog\\'); + expect($module->getTitle())->toBe('Blog'); + expect($module->appNamespace('Filament\\Resources'))->toBe('Modules\\Blog\\Filament\\Resources'); + expect($module->appPath('Filament'))->toEndWith('Blog' . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'Filament'); + expect($module->databasePath('migrations'))->toEndWith('Blog' . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'migrations'); + expect($module->resourcesPath('views'))->toEndWith('Blog' . DIRECTORY_SEPARATOR . 'resources' . DIRECTORY_SEPARATOR . 'views'); +}); + +test('module facade can resolve a scanned module without the global alias', function () { + expect(class_exists(\Module::class, false))->toBeFalse(); + + $this->createTestModule('Blog'); + + expect(Module::find('Blog'))->not->toBeNull(); + expect(Module::isEnabled('Blog'))->toBeTrue(); +}); + +test('filament modules helper can resolve module panels path via macros', function () { + $this->createTestModule('Blog'); + + $panels = FilamentModules::getModulePanels('Blog'); + + expect($panels)->toBeArray(); +}); diff --git a/tests/Unit/ModuleProviderResolutionTest.php b/tests/Unit/ModuleProviderResolutionTest.php new file mode 100644 index 0000000..30199c0 --- /dev/null +++ b/tests/Unit/ModuleProviderResolutionTest.php @@ -0,0 +1,79 @@ +createTestModule('CustomNsModule'); + + $providerPath = $module->appPath('Providers' . DIRECTORY_SEPARATOR . 'CustomNamespaceServiceProvider.php'); + $providerDir = dirname($providerPath); + + if (! is_dir($providerDir)) { + mkdir($providerDir, 0755, true); + } + + file_put_contents($providerPath, <<<'PHP' +toBe('MyCompany\\CRM\\Blog\\Providers\\CustomNamespaceServiceProvider'); + expect(FilamentModules::resolveProviderClass($providerPath)) + ->toBe('MyCompany\\CRM\\Blog\\Providers\\CustomNamespaceServiceProvider'); +}); + +test('can find module name from provider path regardless of namespace', function () { + $module = $this->createTestModule('PathLookupModule'); + + $providerPath = $module->appPath('Providers' . DIRECTORY_SEPARATOR . 'BlogServiceProvider.php'); + + expect(FilamentModules::findModuleNameForPath($providerPath))->toBe('PathLookupModule'); +}); + +test('registers providers that declare custom namespaces when enabled', function () { + $module = $this->createTestModule('CustomProviderModule', enabled: true); + + $providerPath = $module->appPath('Providers' . DIRECTORY_SEPARATOR . 'BlogServiceProvider.php'); + $providerDir = dirname($providerPath); + + if (! is_dir($providerDir)) { + mkdir($providerDir, 0755, true); + } + + file_put_contents($providerPath, <<<'PHP' +toBe('CustomProviderModule'); + expect(str($namespace)->afterLast('\\')->startsWith('Blog'))->toBeTrue(); + + require_once $providerPath; + + $this->app->register($namespace); + + expect(collect($this->app->getProviders($namespace)))->not->toBeEmpty(); +}); diff --git a/tests/Unit/ModuleRuntimeTest.php b/tests/Unit/ModuleRuntimeTest.php new file mode 100644 index 0000000..9b9e1eb --- /dev/null +++ b/tests/Unit/ModuleRuntimeTest.php @@ -0,0 +1,286 @@ +createTestModule('Blog', enabled: true); + + $registry = app(ModuleRegistry::class); + + expect($registry->exists('Blog'))->toBeTrue(); + expect($registry->exists('Missing'))->toBeFalse(); + expect($registry->find('Missing'))->toBeNull(); + + $definition = $registry->find('Blog'); + + expect($definition)->toBeInstanceOf(NwidartModuleDefinition::class); + expect($registry->all())->toHaveCount(1); + expect($registry->all()->first()->name())->toBe('Blog'); +}); + +test('module registry and activator facades proxy to the container bindings', function () { + $this->createTestModule('Blog', enabled: true); + + expect(ModuleRegistryFacade::exists('Blog'))->toBeTrue(); + expect(ModuleRegistryFacade::find('Blog'))->not->toBeNull(); + expect(ModuleRegistryFacade::all())->toHaveCount(1); + expect(ModuleActivatorFacade::isActive('Blog'))->toBeTrue(); + expect(ModuleActivatorFacade::active())->toHaveCount(1); +}); + +test('nwidart module definition exposes manifest metadata and dependencies', function () { + $module = $this->createTestModule('Shop', enabled: true); + $modulePath = $module->getPath(); + + file_put_contents($modulePath . DIRECTORY_SEPARATOR . 'module.json', json_encode([ + 'name' => 'Shop', + 'alias' => 'shop', + 'description' => 'Shop module', + 'keywords' => ['commerce'], + 'priority' => 1, + 'providers' => [], + 'files' => [], + 'depends' => ['Blog'], + ], JSON_THROW_ON_ERROR)); + + $this->clearModuleRepositoryCache(); + + $definition = app(ModuleRegistry::class)->find('Shop'); + + expect($definition)->not->toBeNull(); + expect($definition->name())->toBe('Shop'); + expect($definition->alias())->toBe('shop'); + expect($definition->path())->toBe($module->getPath()); + expect($definition->dependencies())->toBe(['Blog']); + expect($definition->manifest())->toMatchArray([ + 'name' => 'Shop', + 'depends' => ['Blog'], + ]); + expect($definition->module()->getName())->toBe('Shop'); +}); + +test('nwidart module definition returns empty dependencies for invalid manifest values', function () { + $module = $this->createTestModule('Reports', enabled: true); + $modulePath = $module->getPath(); + + file_put_contents($modulePath . DIRECTORY_SEPARATOR . 'module.json', json_encode([ + 'name' => 'Reports', + 'alias' => 'reports', + 'requires' => 'invalid', + ], JSON_THROW_ON_ERROR)); + + $this->clearModuleRepositoryCache(); + + $definition = app(ModuleRegistry::class)->find('Reports'); + + expect($definition?->dependencies())->toBe([]); +}); + +test('file module activator reports enabled and disabled modules', function () { + $this->createTestModule('Blog', enabled: true); + $this->createTestModule('Archive', enabled: false); + + $activator = app(ModuleActivator::class); + $blogDefinition = app(ModuleRegistry::class)->find('Blog'); + + expect($activator->isActive('Blog'))->toBeTrue(); + expect($activator->isActive($blogDefinition))->toBeTrue(); + expect($activator->isActive('Archive'))->toBeFalse(); + expect($activator->active()->map->name()->all())->toBe(['Blog']); +}); + +test('file module activator enables dependencies in activation order', function () { + $blogPath = $this->modulesPath() . DIRECTORY_SEPARATOR . 'Blog'; + $shopPath = $this->modulesPath() . DIRECTORY_SEPARATOR . 'Shop'; + + foreach ([$blogPath, $shopPath] as $path) { + if (! is_dir($path)) { + mkdir($path . DIRECTORY_SEPARATOR . 'app', 0755, true); + } + } + + file_put_contents($blogPath . DIRECTORY_SEPARATOR . 'module.json', json_encode([ + 'name' => 'Blog', + 'alias' => 'blog', + 'providers' => [], + 'files' => [], + ], JSON_THROW_ON_ERROR)); + + file_put_contents($shopPath . DIRECTORY_SEPARATOR . 'module.json', json_encode([ + 'name' => 'Shop', + 'alias' => 'shop', + 'providers' => [], + 'files' => [], + 'requires' => ['Blog'], + ], JSON_THROW_ON_ERROR)); + + $this->clearModuleRepositoryCache(); + + Module::find('Blog')?->disable(); + Module::find('Shop')?->disable(); + + app(ModuleActivator::class)->activate('Shop'); + + expect(Module::isEnabled('Blog'))->toBeTrue(); + expect(Module::isEnabled('Shop'))->toBeTrue(); +}); + +test('file module activator can deactivate a module', function () { + $this->createTestModule('Blog', enabled: true); + + app(ModuleActivator::class)->deactivate('Blog'); + + expect(Module::isEnabled('Blog'))->toBeFalse(); +}); + +test('file module activator rejects per-tenant operations when tenancy is enabled', function () { + $this->createTestModule('Blog', enabled: true); + + config(['filament-modules.tenancy.enabled' => true]); + + $activator = app(ModuleActivator::class); + + expect(fn () => $activator->isActive('Blog', 1))->toThrow(RuntimeException::class); + expect(fn () => $activator->activate('Blog', 1))->toThrow(RuntimeException::class); + expect(fn () => $activator->deactivate('Blog', 1))->toThrow(RuntimeException::class); +}); + +test('file module activator throws when activating an unknown module', function () { + expect(fn () => app(ModuleActivator::class)->activate('Missing')) + ->toThrow(RuntimeException::class, 'Module [Missing] was not found.'); +}); + +test('module dependency resolver handles revisits and missing modules', function () { + $makeDefinition = function (string $name, array $dependencies = []): ModuleDefinition { + return new class($name, $dependencies) implements ModuleDefinition + { + public function __construct( + private string $name, + private array $dependencies, + ) {} + + public function name(): string + { + return $this->name; + } + + public function alias(): string + { + return strtolower($this->name); + } + + public function path(): string + { + return '/modules/' . $this->name; + } + + public function dependencies(): array + { + return $this->dependencies; + } + + public function manifest(): array + { + return ['name' => $this->name, 'requires' => $this->dependencies]; + } + }; + }; + + $registry = Mockery::mock(ModuleRegistry::class); + $registry->shouldReceive('find')->with('A')->andReturn($makeDefinition('A', ['B'])); + $registry->shouldReceive('find')->with('B')->andReturn($makeDefinition('B', ['A'])); + $registry->shouldReceive('find')->with('Missing')->andReturnNull(); + + $resolver = new ModuleDependencyResolver($registry); + + expect($resolver->activationOrder('A'))->toContain('A', 'B'); + expect(fn () => $resolver->activationOrder('Missing')) + ->toThrow(RuntimeException::class, 'Module [Missing] was not found.'); +}); + +test('modules service provider registers custom tenant context from config', function () { + $contextClass = new class implements TenantContext + { + public function resolve(): string | int | null + { + return 'tenant-1'; + } + }; + + config(['filament-modules.tenancy.context' => $contextClass::class]); + + $this->app->forgetInstance(TenantContext::class); + + $provider = $this->app->getProvider(ModulesServiceProvider::class); + $registerModuleRuntime = new ReflectionMethod($provider, 'registerModuleRuntime'); + $registerModuleRuntime->setAccessible(true); + $registerModuleRuntime->invoke($provider); + + expect(app(TenantContext::class))->toBeInstanceOf($contextClass::class); + expect(app(TenantContext::class)->resolve())->toBe('tenant-1'); +}); + +test('modules service provider rejects unsupported activation drivers', function () { + config(['filament-modules.activation.driver' => 'database']); + + $this->app->forgetInstance(ModuleActivator::class); + + $provider = $this->app->getProvider(ModulesServiceProvider::class); + $registerModuleRuntime = new ReflectionMethod($provider, 'registerModuleRuntime'); + $registerModuleRuntime->setAccessible(true); + $registerModuleRuntime->invoke($provider); + + expect(fn () => app(ModuleActivator::class)) + ->toThrow(InvalidArgumentException::class, 'Unsupported module activation driver [database].'); +}); + +test('modules service provider auto discover panels skips registry modules missing from nwidart', function () { + $definition = Mockery::mock(ModuleDefinition::class); + $definition->shouldReceive('name')->andReturn('Ghost'); + + app()->instance(ModuleRegistry::class, new class($definition) implements ModuleRegistry + { + public function __construct(private ModuleDefinition $definition) {} + + public function all(): Collection + { + return collect([$this->definition]); + } + + public function find(string $name): ?ModuleDefinition + { + return $name === 'Ghost' ? $this->definition : null; + } + + public function exists(string $name): bool + { + return $name === 'Ghost'; + } + }); + + app()->instance(ModuleActivator::class, Mockery::mock(ModuleActivator::class, function ($mock): void { + $mock->shouldReceive('isActive')->andReturn(true); + })); + + $provider = $this->app->getProvider(ModulesServiceProvider::class); + $provider->autoDiscoverPanels(); + + $this->app->make('filament'); + + expect(Module::find('Ghost'))->toBeNull(); +}); + +test('default tenant context resolves to null', function () { + expect((new DefaultTenantContext)->resolve())->toBeNull(); +}); diff --git a/tests/Unit/ModulesEdgeCasesTest.php b/tests/Unit/ModulesEdgeCasesTest.php new file mode 100644 index 0000000..851aba8 --- /dev/null +++ b/tests/Unit/ModulesEdgeCasesTest.php @@ -0,0 +1,118 @@ +toBeNull(); +}); + +test('exec command writes output when no console command is provided', function () { + ob_start(); + + app(Modules::class)->execCommand('echo uncovered-output'); + + $output = ob_get_clean(); + + expect(trim($output))->toBe('uncovered-output'); +}); + +test('modules plugin register attaches discovered module plugins', function () { + config()->set('filament-modules.mode', 'both'); + config()->set('filament-modules.auto-register-plugins', true); + + $module = $this->createTestModule('Blog'); + $pluginDir = $module->appPath('Filament'); + + if (! is_dir($pluginDir)) { + mkdir($pluginDir, 0755, true); + } + + file_put_contents($pluginDir . DIRECTORY_SEPARATOR . 'BlogAccessPlugin.php', <<<'PHP' +id('admin')->path('admin'); + $plugin = new ModulesPlugin; + $plugin->register($panel); + + expect($panel->hasPlugin('blog-access'))->toBeTrue(); +}); + +test('modules plugin static helpers resolve plugin instance from panel', function () { + $this->createTestModule('Blog'); + + $pluginClass = new class implements Plugin + { + use ModuleFilamentPlugin; + + public function getModuleName(): string + { + return 'Blog'; + } + + public function getId(): string + { + return 'anonymous-module-plugin'; + } + + public function boot(Panel $panel): void {} + }; + + $panel = $this->registerTestPanel( + Panel::make()->id('admin')->path('admin')->plugin($pluginClass::make()), + ); + + filament()->setCurrentPanel($panel); + + expect($pluginClass::make())->toBeInstanceOf($pluginClass::class); + expect($pluginClass::get())->toBeInstanceOf($pluginClass::class); +}); + +test('modules plugin skips navigation item when module cannot be resolved from panel path', function () { + config()->set('filament-modules.mode', 'panels'); + + $this->registerTestPanel( + Panel::make()->id('orphan-admin')->path('orphan/admin')->brandName('Orphan Admin'), + ); + + $adminPanel = $this->registerTestPanel( + Panel::make()->id('admin')->path('admin'), + ); + + $plugin = new ModulesPlugin; + $plugin->boot($adminPanel); + + expect(collect($adminPanel->getNavigationItems())->map->getLabel()->contains('Orphan Admin'))->toBeFalse(); +}); diff --git a/tests/Unit/ModulesHelperTest.php b/tests/Unit/ModulesHelperTest.php new file mode 100644 index 0000000..762047f --- /dev/null +++ b/tests/Unit/ModulesHelperTest.php @@ -0,0 +1,135 @@ +toBeNull(); +}); + +test('can resolve provider class from file without namespace', function () { + $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'provider-without-namespace.php'; + file_put_contents($path, "toBeNull(); + + unlink($path); +}); + +test('can discover module clusters from disk', function () { + $this->createModuleCluster('Blog', 'Settings'); + + $clusters = FilamentModules::getModuleClusters('Blog'); + + expect($clusters)->toHaveCount(1); + expect($clusters[0])->toBe('Modules\\Blog\\Filament\\Clusters\\Settings\\SettingsCluster'); +}); + +test('returns empty clusters when cluster directory is missing', function () { + $this->createTestModule('Blog'); + + expect(FilamentModules::getModuleClusters('Blog'))->toBe([]); +}); + +test('can resolve module filament page component locations', function () { + $this->createTestModule('Blog'); + + $default = FilamentModules::getModuleFilamentPageComponentLocation('Blog'); + expect($default['namespace'])->toBe('Modules\\Blog\\Filament\\Pages'); + expect($default['viewNamespace'])->toBe('blog'); + expect(is_dir($default['path']))->toBeTrue(); + + $panel = FilamentModules::getModuleFilamentPageComponentLocation('Blog', 'blog-admin'); + expect($panel['namespace'])->toBe('Modules\\Blog\\Filament\\BlogAdmin'); + + $cluster = FilamentModules::getModuleFilamentPageComponentLocation('Blog', forCluster: true); + expect($cluster['namespace'])->toBe('Modules\\Blog\\Filament\\Clusters'); +}); + +test('package path helper resolves package directories', function () { + expect(FilamentModules::packagePath())->toEndWith('filament-modules'); + expect(FilamentModules::packagePath('config'))->toEndWith('filament-modules' . DIRECTORY_SEPARATOR . 'config'); +}); + +test('exec command forwards output to console command', function () { + $command = Mockery::mock(Command::class); + $command->shouldReceive('info')->once()->with('hello'); + + app(Modules::class)->execCommand('echo hello', $command); +}); + +test('get module panels matches registered filament panels', function () { + $this->createModulePanelProvider('Blog', 'AdminPanelProvider'); + + $this->registerTestPanel( + Panel::make()->id('blog-admin')->path('blog/admin'), + ); + + $panels = FilamentModules::getModulePanels('Blog'); + + expect(collect($panels)->map->getId()->all())->toContain('blog-admin'); +}); + +test('returns empty module panels when filament provider directory is missing', function () { + $this->createTestModule('Blog'); + + expect(FilamentModules::getModulePanels('Blog'))->toBe([]); +}); + +test('find module name for path falls back to directory name for invalid module json', function () { + $modulePath = $this->modulesPath() . DIRECTORY_SEPARATOR . 'BrokenJson'; + + if (! is_dir($modulePath)) { + mkdir($modulePath, 0755, true); + } + + file_put_contents($modulePath . DIRECTORY_SEPARATOR . 'module.json', '"not-an-array"'); + + expect(FilamentModules::findModuleNameForPath($modulePath . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'Providers' . DIRECTORY_SEPARATOR . 'Example.php')) + ->toBe('BrokenJson'); +}); + +test('find module name for path stops when filesystem root is reached', function () { + $modulesPath = $this->modulesPath(); + $rootFile = $modulesPath . DIRECTORY_SEPARATOR . 'root-level.php'; + + file_put_contents($rootFile, 'toBeNull(); + } finally { + unlink($rootFile); + } +}); + +test('resolve provider class falls back to converted namespace when file has no namespace', function () { + $module = $this->createTestModule('Blog'); + $providerDir = $module->appPath('Providers'); + + if (! is_dir($providerDir)) { + mkdir($providerDir, 0755, true); + } + + $providerPath = $providerDir . DIRECTORY_SEPARATOR . 'FallbackPanelProvider.php'; + + file_put_contents($providerPath, <<<'PHP' +toBe('Modules\\Blog\\Providers\\FallbackPanelProvider'); +}); + +test('get module filament page component location creates missing view directories', function () { + $module = $this->createTestModule('Blog'); + $location = FilamentModules::getModuleFilamentPageComponentLocation('Blog'); + + expect(is_dir($location['path']))->toBeTrue(); + expect($location['viewNamespace'])->toBe('blog'); +}); diff --git a/tests/Unit/ModulesPluginTest.php b/tests/Unit/ModulesPluginTest.php new file mode 100644 index 0000000..674f020 --- /dev/null +++ b/tests/Unit/ModulesPluginTest.php @@ -0,0 +1,159 @@ +set('filament-modules.auto-register-plugins', true); + + $this->createModuleFilamentPluginFile('Blog'); + + $plugin = new ModulesPlugin; + $method = new ReflectionMethod($plugin, 'getModulePlugins'); + $method->setAccessible(true); + + expect($method->invoke($plugin))->toBe([ + 'Modules\\Blog\\Filament\\BlogAccessPlugin', + ]); +}); + +test('modules plugin skips plugin discovery when auto registration is disabled', function () { + config()->set('filament-modules.auto-register-plugins', false); + + $this->createModuleFilamentPluginFile('Blog'); + + $plugin = new ModulesPlugin; + $method = new ReflectionMethod($plugin, 'getModulePlugins'); + $method->setAccessible(true); + + expect($method->invoke($plugin))->toBe([]); +}); + +test('modules plugin discovers module panels registered in filament', function () { + $this->createModulePanelProvider('Blog', 'AdminPanelProvider'); + + $this->registerTestPanel( + Panel::make()->id('blog-admin')->path('blog/admin'), + ); + + $plugin = new ModulesPlugin; + $method = new ReflectionMethod($plugin, 'getModulePanels'); + $method->setAccessible(true); + + $panels = $method->invoke($plugin); + + expect(collect($panels)->map->getId()->all())->toContain('blog-admin'); +}); + +test('modules plugin boot adds navigation for module panels', function () { + config()->set('filament-modules.mode', 'panels'); + config()->set('filament-modules.panels.group', 'Module Panels'); + + $this->createModulePanelProvider('Blog', 'AdminPanelProvider'); + + $this->registerTestPanel( + Panel::make()->id('blog-admin')->path('blog/admin')->brandName('Blog Admin'), + ); + + $adminPanel = $this->registerTestPanel( + Panel::make()->id('admin')->path('admin'), + ); + + $plugin = new ModulesPlugin; + $plugin->boot($adminPanel); + + $navigation = $adminPanel->getNavigationItems(); + + expect(collect($navigation)->map->getLabel()->contains('Blog Admin'))->toBeTrue(); +}); + +test('modules plugin register skips plugin registration in panels mode', function () { + config()->set('filament-modules.mode', 'panels'); + config()->set('filament-modules.auto-register-plugins', true); + + $this->createModuleFilamentPluginFile('Blog'); + + $panel = Panel::make()->id('admin')->path('admin'); + $plugin = new ModulesPlugin; + $plugin->register($panel); + + expect($panel->getPlugins())->toBeEmpty(); +}); + +test('modules plugin exposes its identifier', function () { + expect((new ModulesPlugin)->getId())->toBe('modules'); +}); + +test('modules plugin make and get helpers resolve the plugin from the panel', function () { + $panel = $this->registerTestPanel( + Panel::make()->id('admin')->path('admin')->plugin(ModulesPlugin::make()), + ); + + filament()->setCurrentPanel($panel); + + expect(ModulesPlugin::make())->toBeInstanceOf(ModulesPlugin::class); + expect(ModulesPlugin::get())->toBeInstanceOf(ModulesPlugin::class); +}); + +test('modules plugin register enables top navigation when cluster config requests it', function () { + config()->set('filament-modules.clusters.enabled', true); + config()->set('filament-modules.clusters.use-top-navigation', true); + config()->set('filament-modules.mode', 'panels'); + + $panel = Panel::make()->id('admin')->path('admin'); + (new ModulesPlugin)->register($panel); + + expect($panel->hasTopNavigation())->toBeTrue(); +}); + +test('modules plugin boot derives navigation labels from panel ids when brand name is missing', function () { + config()->set('filament-modules.mode', 'panels'); + + $this->createModulePanelProvider('Blog', 'AdminPanelProvider'); + + $this->registerTestPanel( + Panel::make()->id('blog-admin')->path('blog/admin')->brandName(''), + ); + + $adminPanel = $this->registerTestPanel( + Panel::make()->id('admin')->path('admin'), + ); + + (new ModulesPlugin)->boot($adminPanel); + + expect(collect($adminPanel->getNavigationItems())->map->getLabel()->contains('admin'))->toBeTrue(); +}); + +test('modules plugin boot skips navigation items when module cannot be resolved from panel path', function () { + config()->set('filament-modules.mode', 'panels'); + + $this->createModulePanelProvider('Blog', 'AdminPanelProvider'); + + $this->registerTestPanel( + Panel::make()->id('blog-admin')->path('missing/admin')->brandName('Blog Admin'), + ); + + $adminPanel = $this->registerTestPanel( + Panel::make()->id('admin')->path('admin'), + ); + + (new ModulesPlugin)->boot($adminPanel); + + expect(collect($adminPanel->getNavigationItems())->filter()->map->getLabel()->contains('Blog Admin'))->toBeFalse(); +}); + +test('modules plugin get module panels skips invalid provider classes and missing modules', function () { + $module = $this->createModulePanelProvider('Blog', 'AdminPanelProvider'); + $providerDir = $module->appPath('Providers' . DIRECTORY_SEPARATOR . 'Filament'); + + file_put_contents($providerDir . DIRECTORY_SEPARATOR . 'InvalidPanelProvider.php', 'modulesPath() . DIRECTORY_SEPARATOR . 'Blog' . DIRECTORY_SEPARATOR . 'module.json'; + unlink($moduleJson); + + $plugin = new ModulesPlugin; + $method = new ReflectionMethod($plugin, 'getModulePanels'); + $method->setAccessible(true); + + expect($method->invoke($plugin))->toBe([]); +}); diff --git a/tests/Unit/ModulesServiceProviderBootTest.php b/tests/Unit/ModulesServiceProviderBootTest.php new file mode 100644 index 0000000..b595e28 --- /dev/null +++ b/tests/Unit/ModulesServiceProviderBootTest.php @@ -0,0 +1,55 @@ +toBeFalse(); + expect($this->app->getProvider(ModulesServiceProvider::class))->toBeInstanceOf(ModulesServiceProvider::class); +}); + +test('modules service provider can register enabled module providers discovered on disk', function () { + $module = $this->createTestModule('Blog', enabled: true); + + $providerPath = $module->appPath('Providers' . DIRECTORY_SEPARATOR . 'BlogServiceProvider.php'); + $providerDir = dirname($providerPath); + + if (! is_dir($providerDir)) { + mkdir($providerDir, 0755, true); + } + + file_put_contents($providerPath, <<<'PHP' +toBe('Modules\\Blog\\Providers\\BlogServiceProvider'); + expect(Module::isEnabled('Blog'))->toBeTrue(); + + $this->app->register($namespace); + + expect(collect($this->app->getProviders($namespace)))->not->toBeEmpty(); +}); + +test('modules service provider condition matches module-owned provider class names', function () { + $module = $this->createTestModule('Blog', enabled: true); + + expect(str('BlogServiceProvider')->startsWith('Blog'))->toBeTrue(); + expect(Module::isEnabled('Blog'))->toBeTrue(); + expect(Module::isEnabled('blog'))->toBeTrue(); +}); diff --git a/tests/Unit/ModulesServiceProviderTest.php b/tests/Unit/ModulesServiceProviderTest.php new file mode 100644 index 0000000..00230b8 --- /dev/null +++ b/tests/Unit/ModulesServiceProviderTest.php @@ -0,0 +1,263 @@ +createModulePanelProvider('Blog', 'BlogAdminPanelProvider'); + + $provider = $this->app->getProvider(ModulesServiceProvider::class); + $provider->autoDiscoverPanels(); + + $this->app->make('filament'); + + expect(class_exists('Modules\\Blog\\Providers\\Filament\\BlogAdminPanelProvider'))->toBeTrue(); + expect($module->isEnabled())->toBeTrue(); +}); + +test('modules service provider skips disabled module providers', function () { + $module = $this->createTestModule('Blog', enabled: false); + + $providerPath = $module->appPath('Providers' . DIRECTORY_SEPARATOR . 'BlogServiceProvider.php'); + $providerDir = dirname($providerPath); + + if (! is_dir($providerDir)) { + mkdir($providerDir, 0755, true); + } + + file_put_contents($providerPath, <<<'PHP' +app->getProvider(ModulesServiceProvider::class); + $provider->attemptToRegisterModuleProviders(); + + expect(collect($this->app->getProviders('Modules\\Blog\\Providers\\BlogServiceProvider')))->toBeEmpty(); +}); + +test('modules service provider registers enabled module providers from disk', function () { + $module = $this->createTestModule('Blog', enabled: true); + + $providerPath = $module->appPath('Providers' . DIRECTORY_SEPARATOR . 'BlogServiceProvider.php'); + $providerDir = dirname($providerPath); + + if (! is_dir($providerDir)) { + mkdir($providerDir, 0755, true); + } + + file_put_contents($providerPath, <<<'PHP' +app->getProvider(ModulesServiceProvider::class); + $provider->attemptToRegisterModuleProviders(); + + expect(collect($this->app->getProviders('Modules\\Blog\\Providers\\BlogServiceProvider')))->not->toBeEmpty(); +}); + +test('modules service provider resolves custom namespace providers', function () { + $module = $this->createTestModule('CustomProviderModule', enabled: true); + + $providerPath = $module->appPath('Providers' . DIRECTORY_SEPARATOR . 'CustomServiceProvider.php'); + $providerDir = dirname($providerPath); + + if (! is_dir($providerDir)) { + mkdir($providerDir, 0755, true); + } + + file_put_contents($providerPath, <<<'PHP' +toBe('CustomProviderModule'); + expect($namespace)->toBe('MyCompany\\Modules\\Custom\\Providers\\CustomServiceProvider'); + + $provider = $this->app->getProvider(ModulesServiceProvider::class); + $provider->attemptToRegisterModuleProviders(); + + expect(collect($this->app->getProviders($namespace)))->toBeEmpty(); +}); + +test('module macros resolve path helpers for nested folders', function () { + $module = $this->createTestModule('Blog'); + + expect($module->migrationsPath('2024'))->toEndWith('database' . DIRECTORY_SEPARATOR . 'migrations' . DIRECTORY_SEPARATOR . '2024'); + expect($module->seedersPath())->toEndWith('database' . DIRECTORY_SEPARATOR . 'seeders'); + expect($module->factoriesPath())->toEndWith('database' . DIRECTORY_SEPARATOR . 'factories'); + expect(Module::find('Blog'))->not->toBeNull(); +}); + +test('modules service provider exposes internal asset and route configuration', function () { + $provider = $this->app->getProvider(ModulesServiceProvider::class); + + $getAssetPackageName = new ReflectionMethod($provider, 'getAssetPackageName'); + $getAssetPackageName->setAccessible(true); + expect($getAssetPackageName->invoke($provider))->toBe('coolsam/modules'); + + foreach (['getAssets', 'getIcons', 'getRoutes', 'getScriptData', 'getMigrations'] as $methodName) { + $method = new ReflectionMethod($provider, $methodName); + $method->setAccessible(true); + + expect($method->invoke($provider))->toBe([]); + } +}); + +test('modules service provider publishes module stubs when running in console', function () { + $stubsPath = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'stubs'; + $stubFile = $stubsPath . DIRECTORY_SEPARATOR . 'coverage-publish.stub'; + + file_put_contents($stubFile, 'coverage stub'); + + try { + expect(app()->runningInConsole())->toBeTrue(); + + $provider = $this->app->getProvider(ModulesServiceProvider::class); + $packageBooted = new ReflectionMethod($provider, 'packageBooted'); + $packageBooted->setAccessible(true); + $packageBooted->invoke($provider); + + expect(array_key_exists( + realpath($stubFile), + ServiceProvider::pathsToPublish(ModulesServiceProvider::class, 'modules-stubs') ?? [], + ))->toBeTrue(); + } finally { + unlink($stubFile); + } +}); + +test('modules install command publishes config and completes successfully', function () { + $configPath = config_path('filament-modules.php'); + + if (file_exists($configPath)) { + unlink($configPath); + } + + $this->artisan('modules:install') + ->assertSuccessful(); + + expect(file_exists($configPath))->toBeTrue(); +}); + +test('modules service provider configurePackage registers optional package directories when present', function () { + $packageRoot = dirname(__DIR__, 2); + $migrationsPath = $packageRoot . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'migrations'; + $viewsPath = $packageRoot . DIRECTORY_SEPARATOR . 'resources' . DIRECTORY_SEPARATOR . 'views'; + + foreach ([$migrationsPath, $viewsPath] as $path) { + if (! is_dir($path)) { + mkdir($path, 0755, true); + } + } + + try { + $provider = $this->app->getProvider(ModulesServiceProvider::class); + $package = (new Package('filament-modules')) + ->setBasePath($packageRoot . DIRECTORY_SEPARATOR . 'src'); + $configurePackage = new ReflectionMethod($provider, 'configurePackage'); + $configurePackage->setAccessible(true); + $configurePackage->invoke($provider, $package); + + expect($package->hasViews)->toBeTrue(); + expect($package->migrationFileNames)->toBe([]); + } finally { + foreach ([$viewsPath, $migrationsPath] as $path) { + if (is_dir($path)) { + rmdir($path); + } + } + } +}); + +test('modules service provider skips providers that do not match the module class prefix', function () { + $module = $this->createTestModule('Blog', enabled: true); + $providerPath = $module->appPath('Providers' . DIRECTORY_SEPARATOR . 'SharedServiceProvider.php'); + $providerDir = dirname($providerPath); + + if (! is_dir($providerDir)) { + mkdir($providerDir, 0755, true); + } + + file_put_contents($providerPath, <<<'PHP' +app->getProvider(ModulesServiceProvider::class); + $provider->attemptToRegisterModuleProviders(); + + expect(collect($this->app->getProviders('Modules\\Blog\\Providers\\SharedServiceProvider')))->toBeEmpty(); +}); + +test('modules service provider auto discover panels skips missing panel classes', function () { + $module = $this->createTestModule('Blog', enabled: true); + $providerDir = $module->appPath('Providers' . DIRECTORY_SEPARATOR . 'Filament'); + + if (! is_dir($providerDir)) { + mkdir($providerDir, 0755, true); + } + + file_put_contents($providerDir . DIRECTORY_SEPARATOR . 'MissingPanelProvider.php', 'app->getProvider(ModulesServiceProvider::class); + $provider->autoDiscoverPanels(); + + expect(class_exists('Modules\\Blog\\Providers\\Filament\\MissingPanelProvider', false))->toBeFalse(); + + $this->app->make('filament'); +}); diff --git a/tests/Unit/ModulesSingletonTest.php b/tests/Unit/ModulesSingletonTest.php index d1d6cb0..6f1aea9 100644 --- a/tests/Unit/ModulesSingletonTest.php +++ b/tests/Unit/ModulesSingletonTest.php @@ -7,3 +7,11 @@ $namespace = FilamentModules::convertPathToNamespace($path); expect($namespace)->toBe($expected = 'Modules\\Providers\\TestServiceProvider', "Expected $expected Instead got " . $namespace); }); + +test('can convert windows style module paths to namespaces', function () { + $base = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, config('modules.paths.modules')); + $path = $base . DIRECTORY_SEPARATOR . 'Blog' . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'Providers' . DIRECTORY_SEPARATOR . 'BlogServiceProvider.php'; + $namespace = FilamentModules::convertPathToNamespace($path); + + expect($namespace)->toBe('Modules\\Blog\\Providers\\BlogServiceProvider'); +}); diff --git a/tests/Unit/ResourceClassTest.php b/tests/Unit/ResourceClassTest.php new file mode 100644 index 0000000..c732d9f --- /dev/null +++ b/tests/Unit/ResourceClassTest.php @@ -0,0 +1,20 @@ +toBeTrue(); + expect(is_subclass_of(Resource::class, FilamentResource::class))->toBeTrue(); + + $source = file_get_contents(dirname(__DIR__, 2) . '/src/Resource.php'); + + expect($source)->toContain('use Filament\Resources\Resource as FilamentResource'); + expect($source)->not->toContain("use Filament\Resources\Resource;\n"); +}); + +test('resource class can be loaded without redeclaration errors', function () { + $reflection = new ReflectionClass(Resource::class); + + expect($reflection->isAbstract())->toBeTrue(); +});