diff --git a/.gitattributes b/.gitattributes index bb6265e..81e7fa6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,11 +1,13 @@ -# Path-based git attributes -# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html - -# Ignore all test and documentation with "export-ignore". +# Keep tests, CI and tooling out of the Composer dist. +/.github export-ignore /.gitattributes export-ignore /.gitignore export-ignore -/.travis.yml export-ignore +/.editorconfig export-ignore /phpunit.xml.dist export-ignore -/.scrutinizer.yml export-ignore +/pint.json export-ignore +/testbench.yaml export-ignore /tests export-ignore -/.editorconfig export-ignore +/workbench export-ignore +/package.json export-ignore +/pnpm-lock.yaml export-ignore +/node_modules export-ignore diff --git a/.github/coverage.svg b/.github/coverage.svg deleted file mode 100644 index facf105..0000000 --- a/.github/coverage.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - coverage - - 97 % - - \ No newline at end of file diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index e68fa6f..0000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: tests - -on: - pull_request: - branches: - - master - push: - branches: - - master - -jobs: - tests: - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: true - matrix: - os: [ ubuntu-latest ] - php: [ 8.4, 8.3, 8.2, 8.1 ] - laravel: [ 13.*, 12.*, 11.*, 10.*, 9.* ] - stability: [ prefer-lowest, prefer-stable ] - include: - - laravel: 13.* - testbench: 11.* - carbon: ^3.8.4 - - laravel: 12.* - testbench: 10.* - carbon: ^3.8.4 - - laravel: 11.* - testbench: ^9.16 - carbon: ^2.63 - - laravel: 10.* - testbench: 8.* - carbon: ^2.63 - - laravel: 9.* - testbench: 7.* - carbon: ^2.63 - exclude: - - laravel: 13.* - php: 8.2 - - laravel: 13.* - php: 8.1 - - laravel: 12.* - php: 8.1 - - laravel: 11.* - php: 8.1 - - name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} - - services: - mysql: - image: mysql:8.0 - env: - MYSQL_USER: user - MYSQL_PASSWORD: secret - MYSQL_DATABASE: generators - MYSQL_ROOT_PASSWORD: secretroot - ports: - - 3306 - options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, pdo_mysql, iconv - coverage: ${{ startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' && 'xdebug' || 'none' }} - - - name: Install dependencies - run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update - composer update --${{ matrix.stability }} --prefer-dist --no-interaction - - - name: Execute tests - run: | - vendor/bin/phpunit ${{ startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' && '--coverage-clover=clover.xml' || '' }} - env: - DB_USERNAME: user - DB_PASSWORD: secret - DB_PORT: ${{ job.services.mysql.ports[3306] }} - - - name: Make code coverage badge - if: startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' - uses: timkrase/phpunit-coverage-badge@v1.2.1 - with: - coverage_badge_path: .github/coverage.svg - push_badge: true - repo_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1642095 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,239 @@ +name: tests + +on: + push: + branches: [master, next-gen] + pull_request: + workflow_dispatch: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # the next-gen packages install from GitHub (VCS repositories) + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"}}' + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: ["8.4", "8.5"] + laravel: ["12.*", "13.*"] + stability: [prefer-lowest, prefer-stable] + include: + - laravel: "12.*" + testbench: "10.*" + - laravel: "13.*" + testbench: "11.*" + + name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.stability }} + + env: + COVERAGE: ${{ matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' }} + + steps: + - uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, pdo, sqlite, pdo_sqlite, zip, intl, gd, exif + coverage: ${{ env.COVERAGE == 'true' && 'xdebug' || 'none' }} + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update + composer require "orchestra/testbench:${{ matrix.testbench }}" --dev --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + + - name: No javaabu/helpers or javaabu/settings v1 in production + run: | + if composer show --tree --no-dev | grep -E "javaabu/helpers|javaabu/settings (v)?1\."; then + echo "javaabu/helpers or javaabu/settings ^1 is a production dependency" + exit 1 + fi + + - name: Run tests + if: env.COVERAGE != 'true' + run: vendor/bin/phpunit + + - name: Run tests with coverage + if: env.COVERAGE == 'true' + run: vendor/bin/phpunit --coverage-clover=clover.xml + + - name: Upload coverage + if: env.COVERAGE == 'true' + uses: codecov/codecov-action@v5 + with: + files: clover.xml + flags: php + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + # Generate a whole admin into a Testbench app and run the generated code's own tests + - name: Generate into the integration app and run its tests + run: | + php workbench/build.php + vendor/bin/phpunit -c workbench/phpunit.generate.xml + vendor/bin/phpunit -c workbench/phpunit.xml + vendor/bin/pint --test --config pint.json build/app/app build/app/database/factories build/app/tests/Feature build/app/routes + + databases: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + database: [mysql, mariadb, pgsql] + + name: Schema on ${{ matrix.database }} + + services: + mysql: + image: ${{ matrix.database == 'mysql' && 'mysql:8.4' || '' }} + env: + MYSQL_DATABASE: generators + MYSQL_ROOT_PASSWORD: secret + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 + mariadb: + image: ${{ matrix.database == 'mariadb' && 'mariadb:11' || '' }} + env: + MARIADB_DATABASE: generators + MARIADB_ROOT_PASSWORD: secret + ports: + - 3307:3306 + options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=10s --health-timeout=5s --health-retries=5 + postgres: + image: ${{ matrix.database == 'pgsql' && 'postgres:17' || '' }} + env: + POSTGRES_DB: generators + POSTGRES_PASSWORD: secret + ports: + - 5432:5432 + options: --health-cmd=pg_isready --health-interval=10s --health-timeout=5s --health-retries=5 + + steps: + - uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + extensions: mbstring, pdo, pdo_mysql, pdo_pgsql, sqlite, pdo_sqlite, zip, intl + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction + + - name: Run tests + run: vendor/bin/phpunit + env: + DB_CONNECTION: ${{ matrix.database }} + DB_HOST: 127.0.0.1 + DB_PORT: ${{ matrix.database == 'mysql' && '3306' || (matrix.database == 'mariadb' && '3307' || '5432') }} + DB_DATABASE: generators + DB_USERNAME: ${{ matrix.database == 'pgsql' && 'postgres' || 'root' }} + DB_PASSWORD: secret + + without-translatable: + runs-on: ubuntu-latest + + name: Without javaabu/translatable + + steps: + - uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + extensions: mbstring, pdo, sqlite, pdo_sqlite, zip, intl, gd, exif + coverage: none + + - name: Install dependencies without javaabu/translatable + run: | + composer remove --dev javaabu/translatable --no-interaction --no-update + composer update --prefer-dist --no-interaction + + - name: Run tests + run: vendor/bin/phpunit + + - name: Generate into the integration app and run its tests + run: | + php workbench/build.php + vendor/bin/phpunit -c workbench/phpunit.generate.xml + vendor/bin/phpunit -c workbench/phpunit.xml + + react: + runs-on: ubuntu-latest + + name: Generated React pages + + steps: + - uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + extensions: mbstring, pdo, sqlite, pdo_sqlite, zip, intl, gd, exif + coverage: none + + - name: Install Composer dependencies + run: composer update --prefer-dist --no-interaction + + - uses: pnpm/action-setup@v4 + + # Keep the store out of pnpm's own node_modules/.bin, where @javaabu/forms' + # prepare build can't emit portable types (Javaabu/react-forms#20). + - name: Use a pnpm store outside node_modules + run: pnpm config set store-dir ~/.pnpm-store + + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + + - name: Install npm dependencies + run: pnpm install --frozen-lockfile + + - name: Generate into the integration app + run: | + php workbench/build.php + vendor/bin/phpunit -c workbench/phpunit.generate.xml + + - name: Typecheck the generated pages + run: pnpm typecheck:app + + - name: Check the generated pages' formatting + run: pnpm format:app + + pint: + runs-on: ubuntu-latest + + name: Pint + + steps: + - uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction + + - name: Check code style + run: vendor/bin/pint --test diff --git a/.gitignore b/.gitignore index 020ca98..5d0f81f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,12 @@ -build +/vendor +/node_modules +/build +/coverage composer.lock -vendor -coverage .phpunit.result.cache +.phpunit.cache +.php-cs-fixer.cache .idea +.DS_Store .env clover.xml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..596ee72 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# generators: notes for AI coding agents + +`javaabu/generators` is a Laravel package (a dev tool): Artisan commands that read a database table and write an admin resource built on the Javaabu next-gen packages (stencil, filters, bulk-actions, permissions, forms, mediapicker, translatable, auth). The package itself depends only on Laravel; the packages the generated code uses are dev dependencies for the integration app. + +## Layout + +| Path | What | +| --- | --- | +| `src/Schema/` | `SchemaInspector` (Laravel's schema builder, normalized columns, enum options from types and check constraints), `Table`, `Column`, `ForeignKey`, `RelationResolver` and `Relations/` | +| `src/Fields/` | One class per column type: cast, factory, stencil field, filter, cells, entries, TS type, test values | +| `src/Resource/` | `ResourceResolver` builds a `ResourceDefinition` (fields, relations, choices, every name) from a table and `ResourceOptions` | +| `src/Generators/` | One generator per part; `files()` returns `GeneratedFile`s (with a merge callback for files that are added to). `Auth/` for generate:auth | +| `src/Support/` | `Stub` (rendering), `Imports` (use statements), `PhpFile` (careful edits of existing PHP), `Naming`, `Translations` | +| `src/Commands/` | `GenerateCommand` (options, prompts, printing, writing) and one command per part | +| `stubs/` | File skeletons (publishable) | +| `tests/` | PHPUnit: `Unit`, `Feature`, `Fixtures/migrations`, `snapshots/` (reviewed output) | +| `workbench/` | The integration app: an overlay on Testbench's skeleton, `generate/GenerateAppTest.php` (the commands), `tests/` (its TestCase and hand-written tests) | +| `docs`, `llms.txt`, `resources/boost` | Docs, the index for assistants, the Laravel Boost guideline and skill | + +## Rules + +- Follow the Javaabu PHP guidelines (`vendor/javaabu/boost-javaabu-guidelines`): snake_case variables and properties, camelCase methods, `#[Test]` methods named `it_…`, early returns, no `else`. +- Generated code must pass Pint with `pint.json`, use the next-gen packages' current APIs, put every user-facing string through `__()`, and use logical CSS only. Generated React pages must typecheck and pass Prettier (`workbench/.prettierrc.json`). +- Never require the packages the generated code uses at runtime: the generators detect them (`class_exists`) and they are `suggest`/`require-dev` only. javaabu/translatable is optional and CI runs without it. +- A change to generated output changes the snapshots: run `GENERATORS_UPDATE_SNAPSHOTS=1 vendor/bin/phpunit` and review the diff. Then run the integration app. +- Every change needs tests, docs (linked from `llms.txt`), the Boost skill if it changes usage, and a line in `CHANGELOG.md`. Strings in `lang/en` and `lang/dv`. + +## Commands + +```bash +composer install && pnpm install # pnpm 10.28 (packageManager) +composer lint && composer test # Pint and PHPUnit (SQLite; DB_CONNECTION=mysql|mariadb|pgsql for the others) +php workbench/build.php # the integration app in build/app +vendor/bin/phpunit -c workbench/phpunit.generate.xml # generate into it +vendor/bin/phpunit -c workbench/phpunit.xml # run the generated tests +vendor/bin/pint --test --config pint.json build/app/app build/app/database/factories build/app/tests/Feature build/app/routes +pnpm typecheck:app && pnpm format:app # the generated React pages +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 8869e77..c2ce926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,21 @@ # Changelog -All notable changes will be documented in this file +All notable changes to `javaabu/generators` are documented in this file. -## 1.0.0 - 202X-XX-XX +## 2.0.0 - unreleased -- initial release +A new major for the Javaabu next-gen packages. See [docs/upgrading.md](docs/upgrading.md). + +- Generated code uses javaabu/stencil forms, javaabu/filters, javaabu/bulk-actions, javaabu/permissions policies and permissions, and Blade (javaabu/forms v2) or Inertia React (@javaabu/forms) pages. +- The schema is read through Laravel's schema builder: MySQL, MariaDB, PostgreSQL and SQLite. +- Relations from the schema: belongsTo, hasMany and many-to-many through pivot tables. +- `--index`, `--form`, `--show` and `--interactive` choose the fields of each page. +- `--parent` nests a resource under another. +- `--media` adds javaabu/mediapicker media collections. +- Optional javaabu/translatable support. +- `generate:auth` creates a javaabu/auth v3 user type and the admin pages of its users. +- New `generate:filters`, `generate:bulk-actions` and `generate:form`. +- The generated tests pass as generated; CI generates a whole admin into a Laravel app and runs them. +- Dhivehi translations of the commands and of the generic phrases of the pages. +- Removed: `generate:request`, the API generators and the per-controller auth generators. +- Requires PHP 8.4+ and Laravel 12+. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index 2d6b321..a523943 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,57 @@ # Generators -[![Latest Version on Packagist](https://img.shields.io/packagist/v/javaabu/generators.svg?style=flat-square)](https://packagist.org/packages/javaabu/generators) -[![Test Status](../../actions/workflows/run-tests.yml/badge.svg)](../../actions/workflows/run-tests.yml) -![Code Coverage Badge](./.github/coverage.svg) -[![Total Downloads](https://img.shields.io/packagist/dt/javaabu/generators.svg?style=flat-square)](https://packagist.org/packages/javaabu/generators) +[![Tests](https://github.com/Javaabu/generators/actions/workflows/tests.yml/badge.svg?branch=next-gen)](https://github.com/Javaabu/generators/actions/workflows/tests.yml?query=branch%3Anext-gen) +[![codecov](https://codecov.io/gh/Javaabu/generators/branch/next-gen/graph/badge.svg)](https://codecov.io/gh/Javaabu/generators/tree/next-gen) +[![Latest Version on Packagist](https://img.shields.io/packagist/v/javaabu/generators.svg)](https://packagist.org/packages/javaabu/generators) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.md) -Laravel generators on steroids +Generate a complete, tested Laravel admin resource from a database table, built on the Javaabu next-gen packages. You design the migration; the generators write the rest. + +```bash +php artisan generate:all products --create +``` + +| Part | What's written | +| --- | --- | +| Model | Casts, and the relations found in the schema: belongsTo, hasMany, many-to-many through pivot tables | +| Factory, policy and permissions | A [javaabu/permissions](https://github.com/Javaabu/permissions) policy and permissions | +| Filters and sorts | [javaabu/filters](https://github.com/Javaabu/filters) | +| Bulk actions | [javaabu/bulk-actions](https://github.com/Javaabu/bulk-actions) | +| Form | A [javaabu/stencil](https://github.com/Javaabu/stencil) form, which also validates | +| Controller and routes | | +| Pages | Blade ([javaabu/forms](https://github.com/Javaabu/forms)) or Inertia React ([@javaabu/forms](https://github.com/Javaabu/react-forms)) | +| Feature tests | They pass as generated | + +```bash +php artisan generate:all products --create --index=name,price --form=name,price,category_id,tags +php artisan generate:all reviews --create --parent=products --views=inertia +php artisan generate:all posts --create --media=featured_image:image,gallery:image:multiple +php artisan generate:auth customers --create # a javaabu/auth user type and its admin pages +``` + +It reads MySQL, MariaDB, PostgreSQL and SQLite schemas. Translatable models with [javaabu/translatable](https://github.com/Javaabu/translatable) are supported but optional. Dhivehi and RTL are built in. ## Documentation -You'll find the documentation on [https://docs.javaabu.com/generators](https://docs.javaabu.com/generators). +You'll find the documentation on [https://docs.javaabu.com/generators](https://docs.javaabu.com/generators), and in the [`docs`](docs) folder. Upgrading from v1? Read [`docs/upgrading.md`](docs/upgrading.md). + +AI coding assistants can start from [`llms.txt`](llms.txt). The package ships a [Laravel Boost](https://github.com/laravel/boost) skill and guideline, which Boost finds by itself. See [AI assistants](docs/ai-assistants.md). Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving this package? Feel free to create an [issue](../../issues) on GitHub, we'll try to address it as soon as possible. If you've found a bug regarding security please mail [info@javaabu.com](mailto:info@javaabu.com) instead of using the issue tracker. - ## Testing -You can run the tests with +```bash +composer test +composer lint -``` bash -./vendor/bin/phpunit +# generate a whole admin into a Laravel app and run its tests +php workbench/build.php +vendor/bin/phpunit -c workbench/phpunit.generate.xml +vendor/bin/phpunit -c workbench/phpunit.xml +pnpm install && pnpm typecheck:app && pnpm format:app ``` ## Changelog diff --git a/composer.json b/composer.json index ee0edab..8795eb5 100644 --- a/composer.json +++ b/composer.json @@ -1,25 +1,71 @@ { "name": "javaabu/generators", - "description": "Laravel generators on steroids", + "description": "Generate a complete, tested Laravel admin resource from a database table: model, policy, permissions, filters, bulk actions, form, controller, routes, Blade or Inertia React views and tests, built on the Javaabu next-gen packages.", "homepage": "https://github.com/Javaabu/generators", "license": "MIT", + "keywords": [ + "javaabu", + "laravel", + "generators", + "scaffolding", + "crud", + "stencil", + "inertia", + "react", + "blade", + "dhivehi" + ], "authors": [ { "name": "Arushad Ahmed", - "email": "arushad@javaabu.com" + "email": "arushad@javaabu.com", + "role": "Developer" + }, + { + "name": "Javaabu Pvt. Ltd.", + "email": "info@javaabu.com", + "homepage": "https://javaabu.com", + "role": "Developer" } ], "require": { - "php": "^8.1", - "illuminate/support": "^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", - "symfony/process": "^6.0 || ^7.0 || ^8.0", - "doctrine/dbal": "^3.6 || ^4.0", - "javaabu/generator-helpers": "^1.3" + "php": "^8.4", + "illuminate/console": "^12.0 || ^13.0", + "illuminate/database": "^12.0 || ^13.0", + "illuminate/filesystem": "^12.0 || ^13.0", + "illuminate/support": "^12.0 || ^13.0", + "javaabu/generator-helpers": "^1.3", + "laravel/prompts": "^0.3.6", + "symfony/process": "^7.2 || ^8.0" }, "require-dev": { - "orchestra/testbench": "^7.0 || ^8.0 || ^9.16 || ^10.0 || ^11.0", - "phpunit/phpunit": "^9.5 || ^10.5 || ^11.5.3 || ^12.5.8 || ^13.0.3", - "javaabu/schema": "^1.2" + "inertiajs/inertia-laravel": "^2.0", + "javaabu/auth": "^3.0@dev", + "javaabu/boost-javaabu-guidelines": "^1.4", + "javaabu/bulk-actions": "^1.0@dev", + "javaabu/exports": "^2.0@dev", + "javaabu/filters": "^1.0@dev", + "javaabu/forms": "^2.0@dev", + "javaabu/manifest": "^1.0@dev", + "javaabu/mediapicker": "^1.0@dev", + "javaabu/permissions": "^3.0@dev", + "javaabu/stencil": "^1.0@dev", + "javaabu/translatable": "^3.0@dev", + "laravel/pint": "^1.24", + "orchestra/testbench": "^10.0 || ^11.0", + "phpunit/phpunit": "^11.5.3 || ^12.5.8" + }, + "suggest": { + "javaabu/stencil": "The forms the generated code uses (^1.0)", + "javaabu/forms": "The Blade components of the generated pages (^2.0)", + "javaabu/filters": "The index filters and sorts the generated code uses (^1.0)", + "javaabu/bulk-actions": "The bulk actions the generated code uses (^1.0)", + "javaabu/permissions": "The policies and permissions the generated code uses (^3.0)", + "javaabu/mediapicker": "Media collections with --media (^1.0)", + "javaabu/translatable": "Translatable models (^3.0)", + "javaabu/auth": "User types with generate:auth (^3.0)", + "javaabu/exports": "Exports with generate:export (^2.0)", + "inertiajs/inertia-laravel": "Inertia React pages with --views=inertia (^2.0)" }, "autoload": { "psr-4": { @@ -28,12 +74,16 @@ }, "autoload-dev": { "psr-4": { - "Javaabu\\Generators\\Tests\\": "tests/" + "Javaabu\\Generators\\Tests\\": "tests/", + "Javaabu\\Generators\\Workbench\\": "workbench/generate/", + "App\\": "build/app/app/", + "Database\\Factories\\": "build/app/database/factories/", + "Tests\\": "build/app/tests/" } }, "extra": { "branch-alias": { - "dev-main": "1.0-dev" + "dev-next-gen": "2.x-dev" }, "laravel": { "providers": [ @@ -41,6 +91,71 @@ ] } }, + "config": { + "sort-packages": true, + "process-timeout": 0 + }, "minimum-stability": "dev", - "prefer-stable": true + "prefer-stable": true, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/Javaabu/block-editor" + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/bulk-actions" + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/filters" + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/forms" + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/js-localization" + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/manifest" + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/permissions" + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/stencil" + }, + { + "type": "vcs", + "url": "https://github.com/n4ff4h/auth" + }, + { + "type": "vcs", + "url": "https://github.com/n4ff4h/exports" + }, + { + "type": "vcs", + "url": "https://github.com/n4ff4h/mediapicker" + }, + { + "type": "vcs", + "url": "https://github.com/n4ff4h/translatable" + } + ], + "scripts": { + "test": "vendor/bin/phpunit", + "test-coverage": "vendor/bin/phpunit --coverage-clover=clover.xml", + "lint": "vendor/bin/pint --test", + "format": "vendor/bin/pint", + "integration": [ + "@php workbench/build.php", + "@php vendor/bin/phpunit -c workbench/phpunit.generate.xml", + "@php vendor/bin/phpunit -c workbench/phpunit.xml" + ] + } } diff --git a/config/config.php b/config/config.php deleted file mode 100644 index fac6aa9..0000000 --- a/config/config.php +++ /dev/null @@ -1,56 +0,0 @@ - env('GENERATORS_TINYINT1_TO_BOOL', true), - - - /* - |-------------------------------------------------------------------------- - | Which columns to always skip - |-------------------------------------------------------------------------- - | - */ - 'skip_columns' => ['created_at', 'updated_at', 'deleted_at'], - - /* - |-------------------------------------------------------------------------- - | Which columns to always skip for auth - |-------------------------------------------------------------------------- - | - */ - 'auth_skip_columns' => [ - 'name', - 'email', - 'email_verified_at', - 'password', - 'remember_token', - 'last_login_at', - 'login_attempts', - 'require_password_update', - 'status', - 'new_email' - ], - - /* - |-------------------------------------------------------------------------- - | Which icon provider to use - |-------------------------------------------------------------------------- - | - */ - 'icon_provider' => \Javaabu\Generators\IconProviders\MaterialDesignIconicProvider::class, - - /* - |-------------------------------------------------------------------------- - | Icon prefix to use for sidebar menu items - |-------------------------------------------------------------------------- - | - */ - 'sidebar_icon_prefix' => 'zmdi-', -]; diff --git a/config/generators.php b/config/generators.php new file mode 100644 index 0000000..e1202ba --- /dev/null +++ b/config/generators.php @@ -0,0 +1,153 @@ + env('GENERATORS_VIEWS', 'blade'), + + /* + |-------------------------------------------------------------------------- + | Database connection + |-------------------------------------------------------------------------- + | + | The connection whose schema is read. Null for the default connection. + | + */ + + 'connection' => null, + + /* + |-------------------------------------------------------------------------- + | The admin area + |-------------------------------------------------------------------------- + | + | Where generated controllers, routes, views and pages go, and who uses + | them. Nothing assumes the web guard or App\Models\User: set the guard + | and user model of your admins (a javaabu/auth user type, for example). + | + */ + + 'admin' => [ + // Controllers go in App\Http\Controllers\{namespace}, tests in Tests\Feature\{namespace} + 'namespace' => 'Admin', + + // URL prefix and route name prefix + 'route_prefix' => 'admin', + 'route_name' => 'admin.', + + // resources/views/{view_prefix}/products, resources/js/pages/{page_prefix}/products + 'view_prefix' => 'admin', + 'page_prefix' => 'admin', + + // The routes file the resource routes are added to, and its middleware + 'routes_file' => 'routes/admin.php', + 'middleware' => ['web', 'auth'], + + // The guard and model of the users of the admin area (null for the default guard) + 'guard' => null, + 'user_model' => 'App\\Models\\User', + ], + + /* + |-------------------------------------------------------------------------- + | Blade + |-------------------------------------------------------------------------- + | + | The layout generated Blade views extend, and the section they fill. + | + */ + + 'blade' => [ + 'layout' => 'layouts.admin', + 'section' => 'content', + ], + + /* + |-------------------------------------------------------------------------- + | Namespaces + |-------------------------------------------------------------------------- + */ + + 'namespaces' => [ + 'models' => 'App\\Models', + 'policies' => 'App\\Policies', + 'filters' => 'App\\Filters', + 'bulk_actions' => 'App\\BulkActions', + 'forms' => 'App\\Stencil', + 'controllers' => 'App\\Http\\Controllers', + 'resources' => 'App\\Http\\Resources', + 'exports' => 'App\\Exports', + 'factories' => 'Database\\Factories', + 'tests' => 'Tests\\Feature', + ], + + /* + |-------------------------------------------------------------------------- + | Schema + |-------------------------------------------------------------------------- + */ + + // Whether tinyint(1) columns are booleans (MySQL) + 'tinyint1_to_bool' => env('GENERATORS_TINYINT1_TO_BOOL', true), + + // Columns that are never fields + 'skip_columns' => ['created_at', 'updated_at', 'deleted_at', 'translations', 'lang', 'remember_token'], + + // Enum classes of columns, for drivers that keep no column comments (SQLite): + // 'orders.status' => App\Enums\OrderStatuses::class. + // On MySQL and PostgreSQL a column comment "enum:App\Enums\OrderStatuses" works too. + 'enums' => [], + + // Columns that name a record, in order of preference + 'name_fields' => ['name', 'title'], + + // How many columns the index table shows by default + 'index_columns' => 5, + + // Tables that are never resources or relations + 'ignored_tables' => [ + 'migrations', 'password_reset_tokens', 'password_resets', 'sessions', 'cache', 'cache_locks', + 'jobs', 'job_batches', 'failed_jobs', 'personal_access_tokens', 'oauth_access_tokens', + 'oauth_auth_codes', 'oauth_clients', 'oauth_refresh_tokens', 'oauth_device_codes', + 'permissions', 'roles', 'model_has_permissions', 'model_has_roles', 'role_has_permissions', + 'media', 'attachments', 'activity_log', 'bulk_action_runs', 'exports', 'imports', + 'languages', 'notifications', 'passkeys', 'telescope_entries', 'telescope_entries_tags', + 'telescope_monitoring', 'pulse_aggregates', 'pulse_entries', 'pulse_values', + ], + + /* + |-------------------------------------------------------------------------- + | Translatable + |-------------------------------------------------------------------------- + | + | 'auto' makes models translatable when javaabu/translatable is installed + | and the table has its translations and lang columns. false never does. + | + */ + + 'translatable' => 'auto', + + /* + |-------------------------------------------------------------------------- + | User types (javaabu/auth) + |-------------------------------------------------------------------------- + */ + + 'auth' => [ + // Columns of a user type that javaabu/auth manages, kept out of forms + 'skip_columns' => [ + 'email_verified_at', 'password', 'remember_token', 'last_login_at', 'login_attempts', + 'require_password_update', 'status', 'new_email', 'two_factor_secret', + 'two_factor_recovery_codes', 'two_factor_confirmed_at', + ], + ], +]; diff --git a/docs/advanced-usage/_category_.json b/docs/advanced-usage/_category_.json index 810cf27..98d7003 100644 --- a/docs/advanced-usage/_category_.json +++ b/docs/advanced-usage/_category_.json @@ -5,6 +5,7 @@ "collapsed": false, "link": { "type": "generated-index", - "slug": "/generators/_categories/advanced-usage" + "slug": "/generators/_categories/advanced-usage", + "description": "Configuration, stubs, translatable models, Dhivehi, databases, the generated tests and extending the generators." } } diff --git a/docs/advanced-usage/configuration.md b/docs/advanced-usage/configuration.md new file mode 100644 index 0000000..b46b388 --- /dev/null +++ b/docs/advanced-usage/configuration.md @@ -0,0 +1,47 @@ +--- +title: Configuration +sidebar_position: 1 +--- + +Publish the config with `php artisan vendor:publish --tag=generators-config`. Every option has a default, so you only change what differs in your app. + +| Option | Default | What | +| --- | --- | --- | +| `views` | `env('GENERATORS_VIEWS', 'blade')` | `blade` or `inertia`, for every command without `--views` | +| `connection` | `null` | The database connection whose schema is read; null for the default | +| `admin.namespace` | `Admin` | Controllers go in `App\Http\Controllers\Admin`, JSON resources in `App\Http\Resources\Admin`, tests in `Tests\Feature\Admin` | +| `admin.route_prefix` | `admin` | The URL prefix of the admin routes | +| `admin.route_name` | `admin.` | The route name prefix: `admin.products.index` | +| `admin.view_prefix` | `admin` | `resources/views/admin/products/` | +| `admin.page_prefix` | `admin` | `resources/js/pages/admin/products/`, `Inertia::render('admin/products/Index')` | +| `admin.routes_file` | `routes/admin.php` | Where resource routes go. Created the first time, and loaded from `routes/web.php` | +| `admin.middleware` | `['web', 'auth']` | The middleware of the admin group, when the routes file is created | +| `admin.guard` | `null` | The admins' guard: policies check it (`$guard`), permissions are made for it (`->guards()`), bulk actions use it, tests log in with it. Null for the default guard | +| `admin.user_model` | `App\Models\User` | The admins' model, which the tests create with its factory | +| `blade.layout` | `layouts.admin` | The layout the Blade pages extend | +| `blade.section` | `content` | The section they fill | +| `namespaces.*` | `App\Models`, `App\Policies`, `App\Filters`, `App\BulkActions`, `App\Stencil`, `App\Http\Controllers`, `App\Http\Resources`, `App\Exports`, `Database\Factories`, `Tests\Feature` | Where each kind of class goes | +| `tinyint1_to_bool` | `true` | Whether MySQL `tinyint(1)` columns are booleans | +| `skip_columns` | `created_at`, `updated_at`, `deleted_at`, `translations`, `lang`, `remember_token` | Columns that are never fields | +| `enums` | `[]` | Enum classes of columns: `'orders.status' => OrderStatuses::class`. See [Enums](../basic-usage/enums.md) | +| `name_fields` | `['name', 'title']` | The columns that name a record, in order: links, selects, page titles. Otherwise the first string column | +| `index_columns` | `5` | How many columns the index table gets by default | +| `ignored_tables` | Framework and package tables | Tables that are never relations | +| `translatable` | `'auto'` | `'auto'` makes models with the translation columns translatable when javaabu/translatable is installed; `false` never does. See [Translatable models](translatable.md) | +| `auth.skip_columns` | The columns javaabu/auth manages | Left out of the admin pages of [user types](../basic-usage/user-types.md) | + +# An admin area on its own guard + +```php +'admin' => [ + 'namespace' => 'Admin', + 'route_prefix' => 'admin', + 'route_name' => 'admin.', + 'view_prefix' => 'admin', + 'page_prefix' => 'admin', + 'routes_file' => 'routes/admin.php', + 'middleware' => ['web', 'auth.type:admin'], + 'guard' => 'web_admin', + 'user_model' => App\Models\Admin::class, +], +``` diff --git a/docs/advanced-usage/customising-stubs.md b/docs/advanced-usage/customising-stubs.md index 9873d6f..d680150 100644 --- a/docs/advanced-usage/customising-stubs.md +++ b/docs/advanced-usage/customising-stubs.md @@ -1,12 +1,64 @@ --- title: Customising stubs -sidebar_position: 1 +sidebar_position: 2 --- -You can customise the stubs used by the generators by publishing them: +The skeleton of each generated file is a stub. Publish them to change what the generators write: ```bash -php artisan vendor:publish --provider="Javaabu\Generators\GeneratorsServiceProvider" --tag="generators-stubs" +php artisan vendor:publish --tag=generators-stubs ``` -The stubs would be located in `stubs/vendors/generators` directory. +They go to `stubs/vendor/generators/`. A published stub is used instead of the package's; delete it to go back. + +| Stub | File | +| --- | --- | +| `model/Model.stub` | The model | +| `factory/Factory.stub` | The factory | +| `policy/Policy.stub` | The policy | +| `filters/Filters.stub` | The filters | +| `bulk-actions/BulkActions.stub` | The bulk actions | +| `form/Form.stub` | The stencil form | +| `controller/Controller.stub` | The controller (its methods are generated) | +| `resource/Resource.stub` | The JSON resource of Inertia pages | +| `routes/admin.stub` | A new admin routes file | +| `views/blade/index.blade.stub`, `create`, `edit`, `show` | The Blade pages | +| `test/ControllerTest.stub` | The tests (the test methods are generated) | +| `export/Export.stub` | The export | +| `auth/migration.stub` | A user type's migration | + +# Placeholders + +Stubs have two kinds of placeholders: + +- **`{{ name }}`, with spaces:** a value or a block of code the generator gives, such as `{{ namespace }}`, `{{ imports }}`, `{{ fillable }}` or `{{ filters }}`. A block alone on its line is indented to that line, and the line disappears when the block is empty. Keep the placeholders you don't change. +- **`{{pluralStudly}}`, without spaces:** the cases of the table name, from javaabu/generator-helpers: `{{pluralStudly}}` (Products), `{{singularSnake}}` (product), `{{pluralKebab}}` (products), `{{pluralLower}}`, `{{singularTitle}}` and the rest. + +```php +// stubs/vendor/generators/model/Model.stub + + */ + protected $fillable = [ + {{ fillable }} + ]; + + {{ body }} +} +``` + +The Inertia pages are written in code rather than from stubs. To change them, generate them and edit them, or [extend the generators](extending.md). diff --git a/docs/advanced-usage/databases-and-column-types.md b/docs/advanced-usage/databases-and-column-types.md new file mode 100644 index 0000000..a8ab055 --- /dev/null +++ b/docs/advanced-usage/databases-and-column-types.md @@ -0,0 +1,41 @@ +--- +title: Databases and column types +sidebar_position: 5 +--- + +The generators read the schema with Laravel's schema builder (`Schema::getColumns()`, `getForeignKeys()`, `getIndexes()`), so they work on MySQL, MariaDB, PostgreSQL and SQLite. CI runs the schema tests on all four. + +# Column types + +| Column | Cast | Form field | Filter | Factory | +| --- | --- | --- | --- | --- | +| `string`, `char`, `uuid` | | `TextInput` with `maxLength()`; `email()`, `tel()` or `url()` for columns named so | in the search | `text()`, `safeEmail()`, ... | +| `text`, `mediumText`, `longText` | | `Textarea` | in the search | `paragraph()` | +| `integer` types | `integer` | `TextInput::integer()` with the type's range | range (on the index) | `numberBetween()` | +| `decimal`, `float`, `double` | `decimal:2` | `TextInput::numeric()->decimals()` | range (on the index) | `randomFloat()` | +| `boolean`, MySQL `tinyint(1)` | `boolean` | `Checkbox` | yes / no | `boolean()` | +| `enum`, or a string with an enum class | the enum class | `Select` with options | select | a random option | +| `date` | `date` | `DatePicker` | date range (on the index) | `date()` | +| `dateTime`, `timestamp` | `datetime` | `DateTimePicker` | date range (on the index) | `dateTimeBetween()` | +| `time` | | `TimePicker` | | `time('H:i')` | +| `year` | `integer` | `TextInput::integer()`, 1901 to 2155 | range (on the index) | `numberBetween()` | +| `json` | `array` | `Select::multiple()->tags()` | | `words()` | +| foreign key | | `Select::relationship()->searchable()` | select | the related factory | + +Binary and other columns are skipped. + +# What each database keeps + +SQLite stores less about a column than the others: + +| | MySQL, MariaDB | PostgreSQL | SQLite | +| --- | --- | --- | --- | +| String lengths | ✓ | ✓ | No: 255 is assumed | +| Decimal precision | ✓ | ✓ | No: 10,2 is assumed | +| Unsigned | ✓ | No | No | +| Enum options | ✓ (type) | ✓ (check constraint) | ✓ (check constraint) | +| Column comments (enum classes) | ✓ | ✓ | No: use `generators.enums` | +| `year` | ✓ | as integer | as integer | +| `json` | ✓ (MariaDB: as text) | ✓ | ✓ | + +Generate from the database you deploy to, or from a MySQL or PostgreSQL copy, for the most exact code. diff --git a/docs/advanced-usage/dhivehi-and-rtl.md b/docs/advanced-usage/dhivehi-and-rtl.md new file mode 100644 index 0000000..354b595 --- /dev/null +++ b/docs/advanced-usage/dhivehi-and-rtl.md @@ -0,0 +1,34 @@ +--- +title: Dhivehi and RTL +sidebar_position: 4 +--- + +# The generated code + +- **Translatable strings.** Every string the generated pages and controllers show goes through `__()`: labels, headings, buttons, messages, options of database enums. Add their Dhivehi to `lang/dv.json`. +- **Generic phrases.** `generate:all` adds Dhivehi for the generic phrases it uses to your `lang/dv.json`, without changing the ones you have: Search, Edit, View, Delete, Restore, Created at and the rest. The phrases of each resource (its name, "New product", "No products yet.") are yours to translate. +- **Right to left.** The pages use logical CSS utilities (`ms-`, `ps-`, `gap-`) and no left or right, so they work right to left. The forms, filters and tables are javaabu/forms' and @javaabu/forms' components, which support RTL and Thaana input. +- **Thaana in the tests.** The generated tests save Thaana text (`'ރަސްމީ'`) to check that it round-trips. + +# The commands + +The generators' own messages and prompts are in `lang/en` and `lang/dv` of the package. Run the commands with `APP_LOCALE=dv` to see them in Dhivehi. + +# To review + +These translations were written without a native speaker's review. Please check them: + +| English | Dhivehi | +| --- | --- | +| Search | ހޯދާ | +| Created at | އުފެއްދި ތާރީޚް | +| Updated at | ބަދަލުކުރި ތާރީޚް | +| Edit | ބަދަލުކުރޭ | +| View | ބަލާ | +| Delete | ފޮހެލާ | +| Restore | އަނބުރާ ގެނޭ | +| Delete permanently | ދާއިމީކޮށް ފޮހެލާ | +| Media | މީޑިއާ | +| Breadcrumb | ނެވިގޭޝަން | +| ID | އައިޑީ | +| The console messages | `lang/dv/generators.php` | diff --git a/docs/advanced-usage/extending.md b/docs/advanced-usage/extending.md new file mode 100644 index 0000000..0dee101 --- /dev/null +++ b/docs/advanced-usage/extending.md @@ -0,0 +1,97 @@ +--- +title: Extending the generators +sidebar_position: 7 +--- + +The generators are a few small classes you can use and extend. + +| Class | What | +| --- | --- | +| `Javaabu\Generators\Schema\SchemaInspector` | Reads a table: `table($name)` returns a `Table` of normalized `Column`s, `ForeignKey`s and the primary key. Also `tableNames()`, `hasTable()` and `driver()`. A singleton, reading `generators.connection` | +| `Javaabu\Generators\Schema\RelationResolver` | `belongsTo($table)`, `hasMany($table)`, `belongsToMany($table)` and `isPivotTable($table)` | +| `Javaabu\Generators\Resource\ResourceResolver` | `resolve($table, ResourceOptions)` returns a `ResourceDefinition`. `makeField($table, $column)` maps a column to a field type, and `nameFieldOf($table)` gives a table's name column | +| `Javaabu\Generators\Resource\ResourceDefinition` | The table, its fields and relations, and the choices. It gives every class, route, view and page name, e.g. `modelClass()`, `routeName('index')`, `viewName('show')`, `pageName('Index')`, `permission('edit')` | +| `Javaabu\Generators\Fields\*` | One class per column type, each knowing its cast, factory value, stencil field, filter, cells, entries, TypeScript type and test values | +| `Javaabu\Generators\Generators\*Generator` | Each part; `files()` returns `GeneratedFile`s (path, contents, and a merge callback for files that are added to) | +| `Javaabu\Generators\Commands\GenerateCommand` | The base command: options, prompts, printing and writing files | + +# A generator of your own + +```php +namespace App\Generators; + +use Javaabu\Generators\Generators\GeneratedFile; +use Javaabu\Generators\Generators\Generator; + +class ApiResourceGenerator extends Generator +{ + public function files(): array + { + $resource = $this->resource; + $class = "App\\Http\\Resources\\Api\\{$resource->model()}Resource"; + + return [new GeneratedFile($resource->classPath($class), $this->render(), description: 'API resource')]; + } + + public function render(): string + { + $fields = collect($this->resource->fields) + ->filter(fn ($field) => $field->isShown()) + ->map(fn ($field, $column) => " '{$column}' => {$field->getResourceValue()},") + ->implode("\n"); + + return "name, '_phone')) { + return new PhoneField($column->name, $column->nullable); + } + + return parent::makeField($table, $column); + } +} + +// AppServiceProvider::register() +$this->app->bind(ResourceResolver::class, AppResourceResolver::class); +``` + +A field type extends `Javaabu\Generators\Fields\Field`, or one of its subclasses such as `StringField`. Implement `fakerCall()`, `stencilClass()`, `getValidValue()` and `getOtherValidValue()`, and override the other methods where it differs. diff --git a/docs/advanced-usage/the-generated-tests.md b/docs/advanced-usage/the-generated-tests.md new file mode 100644 index 0000000..f9c5810 --- /dev/null +++ b/docs/advanced-usage/the-generated-tests.md @@ -0,0 +1,57 @@ +--- +title: The generated tests +sidebar_position: 6 +--- + +`tests/Feature/Admin/ProductsControllerTest.php` tests the generated resource from the outside, through HTTP, and passes as it is generated. + +| Test | Checks | +| --- | --- | +| `it_lists_the_products` | The index shows a product | +| `it_does_not_list_the_products_without_permission` | 403 without `view_products` | +| `it_searches_the_products` | The search finds a product by its name (Thaana) and not another | +| `it_shows_the_new_product_form` | The create page | +| `it_creates_a_product` | A valid post saves every field, relations included | +| `it_validates_new_products` | Invalid values are rejected, nothing is saved | +| `it_does_not_create_products_without_permission` | 403 without `edit_products` | +| `it_shows_a_product` | The show page | +| `it_shows_the_edit_product_form` | The edit page | +| `it_updates_a_product` | A valid patch saves other values | +| `it_does_not_update_products_without_permission` | 403 without `edit_products` | +| `it_attaches_media_to_a_product` | Media from the user's library is attached (with media and a `MediaOwner` admin model) | +| `it_deletes_a_product` | Deleted, or soft deleted | +| `it_restores_a_deleted_product`, `it_permanently_deletes_a_product` | For soft-deleting tables | +| `it_deletes_products_in_bulk` | The bulk delete action | + +For Inertia pages, the tests check the page component and its props with `assertInertia()` instead of the HTML. + +# What they need from the app + +- `Tests\TestCase`, the app's base test case. +- The admins' model (`generators.admin.user_model`) with a factory and javaabu/permissions' `HasRoles`. The tests log in with `actingAsAdmin('view_products')`, which creates a user with those permissions, on `generators.admin.guard`. +- `php artisan permissions:sync` in `setUp()`, which writes the registered permissions. +- The factories of the related tables. Generate those first. +- For Inertia pages, inertia-laravel's testing config: `inertia.testing.page_paths` must include `resource_path('js/pages')` when it checks that pages exist. +- For translatable models, javaabu/translatable's `InteractsWithLanguages`. For media, a fake disk (the tests call `Storage::fake()`). + +If your admins need more to log in (an approved account, a verified email, a role), change `actingAsAdmin()` in the stub: `test/ControllerTest.stub`. + +# How the package tests them + +This package's CI generates a whole admin into a Laravel app (`workbench/`) and runs the generated tests there: + +- every column type, a pivot and soft deletes +- Blade and Inertia pages +- a nested resource +- media +- a translatable model +- a user type + +It also checks the generated PHP with Pint, and type-checks and formats the React pages: + +```bash +php workbench/build.php # build/app: Testbench's skeleton with workbench/ on top +vendor/bin/phpunit -c workbench/phpunit.generate.xml # generate the admin into it +vendor/bin/phpunit -c workbench/phpunit.xml # run the generated tests +pnpm typecheck:app && pnpm format:app # the React pages +``` diff --git a/docs/advanced-usage/translatable.md b/docs/advanced-usage/translatable.md new file mode 100644 index 0000000..ae49eb9 --- /dev/null +++ b/docs/advanced-usage/translatable.md @@ -0,0 +1,45 @@ +--- +title: Translatable models +sidebar_position: 3 +--- + +[javaabu/translatable](https://github.com/Javaabu/translatable) is optional. When it is installed, a table with its two columns gets a translatable model: + +```php +$table->json('translations')->nullable(); +$table->string('lang')->nullable()->index(); +// or $table->jsonTranslatable(); +``` + +```bash +php artisan generate:all articles --create +``` + +```php +// app/Models/Article.php +class Article extends Model implements JsonTranslatable +{ + use HasFactory; + + use IsJsonTranslatable; + + protected array $translatable = ['title', 'body']; +} +``` + +```php +// app/Stencil/ArticleForm.php +TextInput::make('title') + ->label(__('Title')) + ->required() + ->maxLength(255) + ->translatable(), +``` + +- **Which columns:** the string and text columns, except unique ones (slugs, codes) and emails, phones and URLs. Choose others with `--translatable=title,body`. +- **The form:** one input per active language, named `title_en`, `title_dv` and so on. The primary language is required, the others optional (javaabu/translatable's rules). +- **The tests:** they set up English and Dhivehi with translatable's `InteractsWithLanguages`, save a Dhivehi translation and check it. +- **Without the package:** the same table gives a plain model, and the translation columns are skipped. The generators only check `class_exists()` for translatable, so they don't need it installed. +- **Turning it off:** `'translatable' => false` in the config makes plain models even when the package is there. + +`--translatable=title` on a table without the columns stops with the migration lines to add. diff --git a/docs/ai-assistants.md b/docs/ai-assistants.md new file mode 100644 index 0000000..677ff8f --- /dev/null +++ b/docs/ai-assistants.md @@ -0,0 +1,26 @@ +--- +title: AI assistants +sidebar_position: 1.25 +--- + +The docs are plain Markdown, written for people and AI coding assistants alike. The package ships them, so they always match the version you have installed. + +| File | What | +| --- | --- | +| `vendor/javaabu/generators/llms.txt` | The rules for using the generators, and every page | +| `vendor/javaabu/generators/docs/` | The pages | +| `vendor/javaabu/generators/resources/boost/` | The Laravel Boost skill and guideline | + +# Laravel Boost + +[Laravel Boost](https://github.com/laravel/boost) finds the skill and guideline in the Composer package by itself. Run this after installing or updating: + +```bash +php artisan boost:update +``` + +The guideline tells the assistant to generate admin resources with the generators rather than writing them by hand. The skill (`javaabu-generators`) has the commands, options, what each writes, and the common mistakes. It loads when the assistant is asked to scaffold CRUD pages, resources or user types. + +# Other assistants + +Point them at `vendor/javaabu/generators/llms.txt`. diff --git a/docs/basic-usage/_category_.json b/docs/basic-usage/_category_.json index 434cd46..b10c042 100644 --- a/docs/basic-usage/_category_.json +++ b/docs/basic-usage/_category_.json @@ -6,6 +6,6 @@ "link": { "type": "generated-index", "slug": "/generators/_categories/basic-usage", - "description": "Everything you need to know to get started!" + "description": "Generating admin resources, their fields, relations, pages, nesting, media and user types." } } diff --git a/docs/basic-usage/auth-generators.md b/docs/basic-usage/auth-generators.md deleted file mode 100644 index 9180f43..0000000 --- a/docs/basic-usage/auth-generators.md +++ /dev/null @@ -1,157 +0,0 @@ ---- -title: Auth generators -sidebar_position: 3 ---- - -This package allows you to scaffold authentication for new user types according to [javaabu/auth](https://github.com/Javaabu/auth) package. - -# Setting up the auth migration - -To use an auth generator, first create the new user type migration with the `\Javaabu\Auth\UserSchema::columns($table);` columns and run the migration. In this example, we are creating a new user type called `customers`. - -```php -// create_customers_table.php -return new class extends Migration -{ - /** - * Run the migrations. - */ - public function up(): void - { - Schema::create('customers', function (Blueprint $table) { - \Javaabu\Auth\UserSchema::columns($table); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('customers'); - } -}; -``` - -For the default `UserSchema` columns, the auth generators will generate code based on predefined stubs instead of how code is generated by regular generators. You can customise which columns will be considered as `UserSchema` columns by editing the `auth_skip_columns` in the `config/generators.php` config file after you have published the package config. The default auth columns are given below: - -```php - /* - |-------------------------------------------------------------------------- - | Which columns to always skip for auth - |-------------------------------------------------------------------------- - | - */ - 'auth_skip_columns' => [ - 'name', - 'email', - 'email_verified_at', - 'password', - 'remember_token', - 'last_login_at', - 'login_attempts', - 'require_password_update', - 'status', - 'new_email' - ], -``` - -If required, you can also include additional columns in your migration apart from the default columns from the `UserSchema`. These additional columns will be handled by the auth generators as how regular generators handle columns. - -```php -// create_customers_table.php -return new class extends Migration -{ - /** - * Run the migrations. - */ - public function up(): void - { - Schema::create('customers', function (Blueprint $table) { - \Javaabu\Auth\UserSchema::columns($table); - - $table->text('address')->nullable(); - $table->datetime('dob')->index(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('customers'); - } -}; -``` - -# Using auth generators - -Auth generate commands function similar to the regular generate commands, apart from the `--auth_name` option. This option allows you to customize the name used for the auth routes and the namespace. By default, the `auth_name` will be generated using the singular name of the given table. - -For example, running the following command: - -```bash -php artisan generate:auth_routes customers --create -``` - -will generate a route file called `customer.php`. If you wanted to instead have the route file named `portal.php`, you could use the `--auth_name` option like so: - -```bash -php artisan generate:auth_routes customers --auth_name=portal --create -``` - -# Available auth generators - -The available auth generators and what they generate are given below: - -```bash -# creates database/migrations/customer_password_reset_tokens.php -php artisan generate:auth_password_resets customers --create - -# creates database/factories/CustomerFactory.php -php artisan generate:auth_factory customers --create - -# adds auth permissions to database/seeders/PermissionsSeeder.php -php artisan generate:auth_permissions customers --create - -# creates app/Models/Customer.php -# adds causer type, subject type and morph map to app/Providers/AppServiceProvider.php -php artisan generate:auth_model customers --create - -# creates app/Policies/CustomerPolicy.php -php artisan generate:auth_policy customers --create - -# creates app/Http/Requests/CustomersRequest.php -php artisan generate:auth_request customers --create - -# creates app/Http/Controllers/Admin/CustomersController.php -php artisan generate:auth_controller customers --create - -# creates app/Http/Controllers/Customer/Auth/ConfirmPasswordController.php -php artisan generate:auth_confirm_password_controller customers --create - -# creates app/Http/Controllers/Customer/Auth/UpdatePasswordController.php -php artisan generate:auth_update_password_controller customers --create - -# creates app/Http/Controllers/Customer/Auth/ResetPasswordController.php -php artisan generate:auth_reset_password_controller customers --create - -# creates app/Http/Controllers/Customer/Auth/ForgotPasswordController.php -php artisan generate:auth_forgot_password_controller customers --create - -# creates app/Http/Controllers/Customer/Auth/LoginController.php -php artisan generate:auth_login_controller customers --create - -# creates app/Http/Controllers/Customer/Auth/RegisterController.php -php artisan generate:auth_register_controller customers --create - -# creates app/Http/Controllers/Customer/Auth/VerificationController.php -php artisan generate:auth_verification_controller customers --create - -# creates app/Http/Controllers/Customer/HomeController.php -php artisan generate:auth_home_controller customers --create - -# adds auth config to config/auth.php config file -php artisan generate:auth_config customers --create -``` diff --git a/docs/basic-usage/available-generators.md b/docs/basic-usage/available-generators.md index 7b59482..cd10b75 100644 --- a/docs/basic-usage/available-generators.md +++ b/docs/basic-usage/available-generators.md @@ -3,67 +3,90 @@ title: Available generators sidebar_position: 2 --- -The available generators and what they generate are given below: - -```bash -# creates database/factories/ProductFactory.php -php artisan generate:factory products --create - -# adds permissions to database/seeders/PermissionsSeeder.php -php artisan generate:permissions products --create - -# creates app/Models/Product.php -# adds subject type and morph map to app/Providers/AppServiceProvider.php -php artisan generate:model products --create - -# creates app/Policies/ProductPolicy.php -php artisan generate:policy products --create - -# creates app/Http/Requests/ProductsRequest.php -php artisan generate:request products --create - -# creates app/Exports/ProductsExport.php -php artisan generate:export products --create - -# creates app/Http/Controllers/Admin/ProductsController.php -php artisan generate:controller products --create - -# adds routes to routes/admin.php -php artisan generate:routes products --create - -# creates app/Http/Controllers/Api/ProductsController.php -# adds api routes to api.php routes file -php artisan generate:api_controller products --create - -# creates tests/Feature/Controllers/Api/ProductsControllerTest.php -php artisan generate:api_test products --create - -# creates app/Http/Controllers/Api/ProductsController.php -# adds api routes to api.php routes file -# creates tests/Feature/Controllers/Api/ProductsControllerTest.php -php artisan generate:api products --create {--except=} +Every command takes a table and [the same options](generating-a-resource.md#the-options). Without `--create`, it prints the code. + +| Command | Writes | +| --- | --- | +| `generate:all {table}` | Everything below, except the export | +| `generate:model {table}` | `app/Models/Product.php`: fillable fields, casts, relations, soft deletes, media collections, translations | +| `generate:factory {table}` | `database/factories/ProductFactory.php` | +| `generate:policy {table}` | `app/Policies/ProductPolicy.php`, a javaabu/permissions `ResourcePolicy` | +| `generate:permissions {table}` | Adds `Permissions::resource('products', label: 'Products')` to `AppServiceProvider::boot()` | +| `generate:filters {table}` | `app/Filters/ProductFilters.php` (javaabu/filters) | +| `generate:bulk-actions {table}` | `app/BulkActions/ProductBulkActions.php` (javaabu/bulk-actions) | +| `generate:form {table}` | `app/Stencil/ProductForm.php` (javaabu/stencil) | +| `generate:controller {table}` | `app/Http/Controllers/Admin/ProductsController.php` | +| `generate:routes {table}` | Adds the routes to `routes/admin.php` | +| `generate:views {table}` | The Blade views, or with `--views=inertia` the React pages, their types and `app/Http/Resources/Admin/ProductResource.php` | +| `generate:test {table}` | `tests/Feature/Admin/ProductsControllerTest.php` | +| `generate:export {table}` | `app/Exports/ProductsExport.php` (javaabu/exports) | +| `generate:auth {table}` | A javaabu/auth user type and the admin pages of its users: [User types](user-types.md) | +| `generate:rollback` | Discards every uncommitted change: [Rolling back](rolling-back-changes.md) | + +# What each part writes + +**Model.** +- `$fillable` lists every column the form saves, foreign keys included. +- `casts()` covers integers, decimals, booleans, dates, JSON arrays and enum classes. +- Relations are found in the schema (see [Relations](relations.md)). +- `SoftDeletes` when the table has `deleted_at`. + +**Factory.** A realistic value for every column. Foreign keys get a factory of the related model, unique columns unique values, and nullable columns sometimes `null`. + +**Policy and permissions.** The `ResourcePolicy` maps the abilities to the resource's permissions: + +| Ability | Permission | +| --- | --- | +| `viewAny`, `view` | `view_products` | +| `create`, `update` | `edit_products` | +| `delete` | `delete_products` | +| `restore` | `delete_products` or `force_delete_products` | +| `forceDelete` | `force_delete_products` | + +Laravel finds the policy by its name. `php artisan permissions:sync` writes the permissions. + +**Filters.** +- `search` over the text columns +- selects for enums, foreign keys and many-to-many relations +- yes/no filters for booleans +- ranges for the number and date columns on the index table +- `created_at` +- trashed records for soft-deleting tables +- a sort for each sortable column on the index table + +**Bulk actions.** Delete, plus restore and permanently delete for soft-deleting tables, each behind its permission. They run on the selected rows or on every row matching the filters. + +**Form.** One stencil field per form column, in a section. Stencil derives the validation rules from the fields: required, types, lengths, ranges, `in:` options, `unique`. So there is no FormRequest. + +**Controller.** +- `index`, `create`, `store`, `show`, `edit`, `update` and `destroy`, plus `restore` and `forceDelete` for soft-deleting tables. +- It authorizes every action with the policy. +- `store` and `update` call `ProductForm::make()->model(...)->save()`: validation, filling, saving and syncing many-to-many relations and media. + +**Routes.** In the admin group of `routes/admin.php`: + +```php +// Products +Route::resource('products', ProductsController::class)->withTrashed(['show']); +Route::patch('products/{product}/restore', [ProductsController::class, 'restore'])->withTrashed()->name('products.restore'); +Route::delete('products/{product}/force-delete', [ProductsController::class, 'forceDelete'])->withTrashed()->name('products.force-delete'); +Route::bulkActions('products', ProductBulkActions::class); +``` -# creates tests/Feature/Controllers/Admin/ProductsControllerTest.php -php artisan generate:test products --create +**Views.** The index page has the filter bar, a sortable table with bulk actions and pagination. The create and edit pages render the form. The show page has an infolist and the edit, delete and restore buttons. See [Blade and Inertia React](blade-and-inertia.md). -# creates resources/views/admin/products/products.blade.php -# creates resources/views/admin/products/_actions.blade.php -# creates resources/views/admin/products/_form.blade.php -# creates resources/views/admin/products/create.blade.php -# creates resources/views/admin/products/edit.blade.php -# creates resources/views/admin/products/_details.blade.php -# creates resources/views/admin/products/show.blade.php -# creates resources/views/admin/products/_bulk.blade.php -# creates resources/views/admin/products/_list.blade.php -# creates resources/views/admin/products/_table.blade.php -# creates resources/views/admin/products/index.blade.php -# adds links to app/Menus/AdminSidebar.php -php artisan generate:views products --create +**Tests.** The tests cover listing, searching, every form, creating, validation, showing, updating, deleting, restoring, bulk deleting and the permission checks. See [The generated tests](../advanced-usage/the-generated-tests.md). -# creates all files specified above except API controller -# To skip a command, give comma separated list of commands -php artisan generate:all products --create {--except=} +**Export.** -# To generate all except controllers and tests -php artisan generate:all products --create --except=controller,test +```php +class ProductsExport extends ModelExport +{ + public function modelClass(): string + { + return Product::class; + } +} ``` + +Return `ProductsExport::make($filters->apply(Product::query()))->download('products.xlsx')` from the index when `$request->filled('download')`, and add `export` to ``. diff --git a/docs/basic-usage/blade-and-inertia.md b/docs/basic-usage/blade-and-inertia.md new file mode 100644 index 0000000..56484d9 --- /dev/null +++ b/docs/basic-usage/blade-and-inertia.md @@ -0,0 +1,99 @@ +--- +title: Blade and Inertia React +sidebar_position: 6 +--- + +```bash +php artisan generate:all products --create # Blade (the default) +php artisan generate:all products --create --views=inertia # Inertia React +``` + +Change the default with `GENERATORS_VIEWS=inertia` or `generators.views`. The model, filters, bulk actions, form and policy are the same for both; the controller, the pages and the tests differ. + +# Blade + +`resources/views/admin/products/` has `index`, `create`, `edit` and `show`. They extend `generators.blade.layout` (`layouts.admin`) and fill its `content` section. + +```blade +{{-- index.blade.php --}} + + + + + + + + + + + + + + @forelse($products as $product) + + + {{ $product->name }} + + + + @empty + ... + @endforelse + + +``` + +```blade +{{-- create.blade.php and edit.blade.php --}} +{{ $form }} +``` + +The show page is an `` with an entry per field, and buttons that check the policy with `@can`. The controller passes `filters`, `bulk_actions` and the paginator to the index, and `form` to the create and edit pages. + +# Inertia React + +`resources/js/pages/admin/products/` has `Index.tsx`, `Create.tsx`, `Edit.tsx`, `Show.tsx` and `types.ts`, in TypeScript. The controller renders them with `Inertia::render('admin/products/Index', [...])` and sends the records through `App\Http\Resources\Admin\ProductResource`. The resource holds the attributes, the loaded relations, the media URLs, the record's `urls` and what the user `can` do with it. + +```tsx +// Index.tsx +import { BulkActionsTable, type BulkActionsSchema } from "@javaabu/bulk-actions" +import { FilterBar, SortableHeading, type FiltersData } from "@javaabu/filters" +import { type LaravelResourceCollection, Pagination, TableCell, TableRow } from "@javaabu/forms" +import { __ } from "@javaabu/i18n" +import type { Product } from "./types" + +export default function Index({ products, filters, bulkActions, createUrl }: Props) { + return ( + <> + + + router.reload()} + headers={} + pagination={products.meta.last_page > 1 ? : null} + > + {products.data.map((product) => ( + + + + ))} + + + ) +} +``` + +```tsx +// Create.tsx and Edit.tsx + +``` + +- `@javaabu/stencil`, `@javaabu/filters`, `@javaabu/bulk-actions` and `@javaabu/mediapicker` are the packages' own React code, aliased by javaabu/manifest's Vite plugin. `@javaabu/forms` and `@javaabu/i18n` are npm packages. +- The pages have no layout of their own: give them one with a persistent layout in `app.tsx`. +- `php artisan manifest:types` gives TypeScript the package paths. +- The generated pages are type-checked and formatted with Prettier in this package's CI (`semi: false`, `printWidth: 120`). + +# Directions + +Both kinds of pages use logical CSS (`ms-`, `ps-`, `gap-`), so they work in right-to-left layouts. See [Dhivehi and RTL](../advanced-usage/dhivehi-and-rtl.md). diff --git a/docs/basic-usage/choosing-fields.md b/docs/basic-usage/choosing-fields.md new file mode 100644 index 0000000..72a19cf --- /dev/null +++ b/docs/basic-usage/choosing-fields.md @@ -0,0 +1,48 @@ +--- +title: Choosing fields +sidebar_position: 3 +--- + +By default: + +- **The form** has every column, except the key, timestamps, soft deletes, the translation columns and (for a nested resource) the parent's key. Many-to-many relations are included too. +- **The index table** has the name column (`name`, `title`, or the first string) and up to four more short columns. Long text and JSON are left out. +- **The show page** has every column and many-to-many relation. + +# With options + +```bash +php artisan generate:all products --create \ + --index=name,price,status,category_id \ + --form=name,sku,price,status,category_id,tags \ + --show=name,sku,description,price,status,category_id,tags,created_at +``` + +- Give column names, and many-to-many relations by their relation name (`tags`). +- The name column is always on the index table, as the link to the show page. +- An unknown name stops the command with a list of what it doesn't know. +- `generators.index_columns` sets how many columns the index table gets by default. + +`--columns` restricts every part to those columns, model and factory included: + +```bash +php artisan generate:form products --columns=name,price +``` + +# With prompts + +```bash +php artisan generate:all products --create --interactive +``` + +The prompts show checklists of the fields with the defaults ticked, for the index table, the form and the show page. They then ask for the parent of a nested resource, media collections and the views. An option you pass on the command line isn't asked for. + +``` + ┌ Which fields go in the form? ────────────────────────────────┐ + │ › ◼ Name (name) │ + │ ◼ Sku (sku) │ + │ ◼ Description (description) │ + │ ◻ Is featured (is_featured) │ + │ ◼ Tags (many-to-many) │ + └───────────────────────────────────────────────────────────────┘ +``` diff --git a/docs/basic-usage/enums.md b/docs/basic-usage/enums.md new file mode 100644 index 0000000..1d7e682 --- /dev/null +++ b/docs/basic-usage/enums.md @@ -0,0 +1,47 @@ +--- +title: Enums +sidebar_position: 5 +--- + +A column with a fixed set of values becomes a select in the form, a select filter and a random value in the factory. + +# Database enums + +```php +$table->enum('status', ['draft', 'published']); +``` + +The options come from the column. MySQL and MariaDB keep them in the type; PostgreSQL and SQLite keep them in a check constraint, which the generators read. The labels are translated: + +```php +Select::make('status') + ->label(__('Status')) + ->required() + ->options(['draft' => __('Draft'), 'published' => __('Published')]), +``` + +# Enum classes + +To use a PHP enum, name it in the column's comment: + +```php +$table->string('status')->comment('enum:App\Enums\OrderStatuses'); +``` + +Or in `config/generators.php`, which works on every database, SQLite included: + +```php +'enums' => [ + 'orders.status' => App\Enums\OrderStatuses::class, +], +``` + +The model casts the column to the enum. The form and the filters take the options from the enum, labelled by its `labels()` or `getLabel()`. The factory picks a random case, and the tests use the cases: + +```php +'status' => OrderStatuses::class, // casts() +Select::make('status')->options(OrderStatuses::class), // the form +'status' => fake()->randomElement(OrderStatuses::cases()), // the factory +``` + +The enum should be backed, and have at least two cases for the update test to change it. diff --git a/docs/basic-usage/generating-a-resource.md b/docs/basic-usage/generating-a-resource.md new file mode 100644 index 0000000..6e3c1f8 --- /dev/null +++ b/docs/basic-usage/generating-a-resource.md @@ -0,0 +1,109 @@ +--- +title: Generating a resource +sidebar_position: 1 +--- + +A resource is everything the admin area needs for one table: model, factory, policy, permissions, filters, bulk actions, form, controller, routes, pages and tests. + +# 1. Write the migration + +The generators read the table, so design it first and migrate: + +```php +Schema::create('products', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('sku', 20)->unique(); + $table->text('description')->nullable(); + $table->decimal('price', 10, 2)->unsigned(); + $table->enum('status', ['draft', 'published']); + $table->boolean('is_featured')->default(false); + $table->foreignId('category_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + $table->softDeletes(); +}); +``` + +Everything in the generated code comes from this schema: + +| From the schema | In the generated code | +| --- | --- | +| Column types | The casts, the form fields and their validation, the factory values and the filters | +| `nullable()` and defaults | Whether fields are required | +| `unique()` | `->unique()` rules and unique factory values | +| Foreign keys | Relations and selects | +| `softDeletes()` | Restore and permanently delete | + +```bash +php artisan migrate +``` + +# 2. See the code + +Without `--create`, a command prints the code instead of writing it: + +```bash +php artisan generate:all products +``` + +# 3. Write the files + +```bash +php artisan generate:all products --create +``` + +``` + app/Models/Product.php ...................................... created + database/factories/ProductFactory.php ....................... created + app/Policies/ProductPolicy.php .............................. created + app/Providers/AppServiceProvider.php ........................ updated + app/Filters/ProductFilters.php .............................. created + app/BulkActions/ProductBulkActions.php ...................... created + app/Stencil/ProductForm.php ................................. created + app/Http/Controllers/Admin/ProductsController.php ........... created + routes/admin.php ............................................ updated + routes/web.php ............................................ unchanged + resources/views/admin/products/index.blade.php .............. created + resources/views/admin/products/create.blade.php ............. created + resources/views/admin/products/edit.blade.php ............... created + resources/views/admin/products/show.blade.php ............... created + tests/Feature/Admin/ProductsControllerTest.php .............. created + lang/dv.json ................................................ updated +``` + +Some files are added to rather than replaced: the service provider, the routes and `lang/dv.json`. The additions are made once, so running a command again doesn't repeat them. Files that exist are skipped unless you pass `--force`. + +Generate the tables a resource relates to first (here, `categories`): its factory and tests use their factories. + +# 4. Finish up + +```bash +php artisan permissions:sync # write the new permissions +php artisan test --filter=ProductsControllerTest +``` + +Give your admin role the permissions (a `super_admin` role with `'*'` has them already), then open `/admin/products`. + +# Skipping parts + +```bash +# everything but the tests and the Dhivehi phrases +php artisan generate:all products --create --except=test,translations +``` + +The parts are `model`, `factory`, `policy`, `permissions`, `filters`, `bulk-actions`, `form`, `controller`, `routes`, `views`, `test` and `translations`. Each also has its own command: see [Available generators](available-generators.md). + +# The options + +| Option | What | +| --- | --- | +| `--create`, `-c` | Write the files instead of printing the code | +| `--force`, `-f` | Overwrite files that exist | +| `--columns=name,price` | Only use these columns | +| `--index=`, `--form=`, `--show=` | Choose the fields of the index table, the form and the show page: [Choosing fields](choosing-fields.md) | +| `--interactive`, `-i` | Choose the fields, the parent, the media and the views with prompts | +| `--views=blade` or `inertia` | [Blade or Inertia React](blade-and-inertia.md) pages | +| `--parent=categories` | [Nest the resource](nested-resources.md) under a parent | +| `--media=featured_image:image` | [Media collections](media.md) | +| `--translatable[=title,body]` | [Translatable models](../advanced-usage/translatable.md) | +| `--except=test,views` | Skip parts (`generate:all` and `generate:auth`) | diff --git a/docs/basic-usage/media.md b/docs/basic-usage/media.md new file mode 100644 index 0000000..1cfc2a8 --- /dev/null +++ b/docs/basic-usage/media.md @@ -0,0 +1,70 @@ +--- +title: Media +sidebar_position: 8 +--- + +A resource can have media collections, picked from the [javaabu/mediapicker](https://github.com/Javaabu/mediapicker) media library: + +```bash +php artisan generate:all posts --create --media=featured_image:image,gallery:image:multiple,documents:document:multiple +``` + +Each collection is `name[:type][:multiple]`: + +- **The type** is one of `image` (the default), `video`, `audio`, `document` or `archive`. +- **`multiple`** allows more than one file. + +The collections need no columns: mediapicker keeps attachments in its own table. + +What's generated: + +```php +// app/Models/Post.php +class Post extends Model implements HasAttachments +{ + use InteractsWithAttachments; + + public function registerAttachmentCollections(): void + { + $this->addAttachmentCollection('featured_image') + ->singleFile() + ->acceptsFile(fn (File $file) => FileTypes::isAllowed($file->mimeType, 'image')); + + $this->addAttachmentCollection('gallery') + ->acceptsFile(fn (File $file) => FileTypes::isAllowed($file->mimeType, 'image')); + } +} +``` + +```php +// app/Stencil/PostForm.php: a section of pickers +Section::make(__('Media')) + ->schema([ + MediaPickerField::make('featured_image') + ->label(__('Featured image')) + ->mediaType('image'), + MediaPickerField::make('gallery') + ->label(__('Gallery')) + ->mediaType('image') + ->multiple(), + ]), +``` + +The form saves the picked media after the post (stencil calls the picker's `saveRelationship()`). + +The show page shows the images with ``, and lists the other files. The JSON resource of Inertia pages sends their URLs. + +The routes file gets the media library of the admin area, once: + +```php +Route::middleware('web')->group(function () { + Route::mediapicker(['prefix' => 'admin/media', 'name' => 'admin.media']); +}); +``` + +# Setting up mediapicker + +- Set `media-library.media_model` to `Javaabu\Mediapicker\Models\Media` and run the migrations, as in [mediapicker's setup](https://github.com/Javaabu/mediapicker). +- The admins' model uploads to the library: it implements `MediaOwner`, with `InteractsWithMedia` and `OwnsMedia`. +- When it does, the generated tests also attach media from the user's library. Otherwise they skip media. +- For Inertia pages, share `Mediapicker::pickerConfig()` as `mediapicker` and wrap the app in `MediapickerProvider`. The generated forms pass `StencilMediaPicker` to ``. diff --git a/docs/basic-usage/nested-resources.md b/docs/basic-usage/nested-resources.md new file mode 100644 index 0000000..364986f --- /dev/null +++ b/docs/basic-usage/nested-resources.md @@ -0,0 +1,31 @@ +--- +title: Nested resources +sidebar_position: 7 +--- + +A resource that belongs to another can be managed under it: a product's reviews at `/admin/products/{product}/reviews`. + +```bash +php artisan generate:all reviews --create --parent=products +``` + +`--parent` takes the parent table (`products`), the foreign key (`product_id`) or the relation (`product`). It must be a foreign key of the table. + +What changes: + +- **Routes.** `Route::resource('products.reviews', ...)->scoped()` gives `admin.products.reviews.index` and the rest, with scoped bindings: a review from another product is a 404. The restore, permanently delete and bulk action routes are nested the same way. + + ```php + Route::resource('products.reviews', ReviewsController::class)->scoped(); + Route::bulkActions('products/{product}/reviews', ReviewBulkActions::class, ['name' => 'products.reviews.bulk']); + ``` + +- **Controller.** Every action takes the parent. The index lists only its records (`Review::query()->whereBelongsTo($product, 'product')`), and new records belong to it (`(new Review)->product()->associate($product)`). +- **Form.** The parent's foreign key isn't a field: the URL gives it. +- **Bulk actions.** They select only the parent's records, and post to the nested route. +- **Pages.** They show the parent's name, and get the parent as `$product` (Blade) or `product` (Inertia). +- **Tests.** Each test creates a parent and the records under it. + +`scoped()` finds the record through the parent's relation named after the child (`Product::reviews()`). Generate the parent first, so its model has the relation. + +The policy is the resource's own. To also check the parent (for example, only editors of a product may see its reviews), add `Gate::authorize('view', $product)` to the controller's actions. diff --git a/docs/basic-usage/relations.md b/docs/basic-usage/relations.md new file mode 100644 index 0000000..bdd6557 --- /dev/null +++ b/docs/basic-usage/relations.md @@ -0,0 +1,114 @@ +--- +title: Relations, filters and bulk actions +sidebar_position: 4 +--- + +The generators read every table's foreign keys to find the relations. + +# belongsTo + +Each foreign key of the table: + +```php +$table->foreignId('category_id')->constrained(); +$table->string('product_slug'); +$table->foreign('product_slug')->references('slug')->on('products'); +``` + +```php +public function category(): BelongsTo +{ + return $this->belongsTo(Category::class); +} + +public function product(): BelongsTo +{ + return $this->belongsTo(Product::class, 'product_slug', 'slug'); +} +``` + +The relation is named after the key without `_id`, or after the related table when the key starts with its name (`product_slug`). + +In the form, a foreign key is a searchable select of the related records, labelled by their name column: + +```php +Select::make('category_id') + ->label(__('Category')) + ->required() + ->relationship('category', 'name') + ->searchable(), +``` + +The index table shows `$product->category?->name`, and the controller eager loads it. + +# hasMany + +Foreign keys on other tables that point at this one: `products.category_id` gives `Category::products()`. When a table has two keys to the same table, each relation is named after its key (`author_id` gives `authorPosts`, `reviewer_id` gives `reviewerPosts`). + +# Many-to-many + +A pivot table joins two tables through their keys. It has two foreign keys and nothing else but an id and timestamps, or it is named after the two tables (`product_tag`) and may have more pivot columns. + +```php +Schema::create('product_tag', function (Blueprint $table) { + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('tag_id')->constrained()->cascadeOnDelete(); + $table->primary(['product_id', 'tag_id']); +}); +``` + +Both models get the relation: `Product::tags()` and `Tag::products()`. Other names, keys, pivot columns and timestamps are written out: + +```php +return $this->belongsToMany(Tag::class, 'labels', 'item_id', 'label_id')->withPivot('position')->withTimestamps(); +``` + +In the form, the relation is a multiple select, which stencil syncs after saving: + +```php +Select::make('tags') + ->label(__('Tags')) + ->multiple() + ->relationship('tags', 'name') + ->searchable(), +``` + +A pivot table isn't a resource of its own: `generate:all product_tag` says to generate `products` and `tags` instead. + +# Filters + +`ProductFilters` (javaabu/filters) gets: + +| Column | Filter | +| --- | --- | +| The string and text columns | One `TextFilter::make('search')->columns([...])` | +| Enums | `SelectFilter` with the options or the enum class | +| Foreign keys | `SelectFilter::make('category_id')->relationship('category', 'name')->searchable()` | +| Many-to-many relations | `SelectFilter::make('tags')->relationship('tags', 'name')->multiple()` | +| Booleans | `TernaryFilter` (yes, no, any) | +| Numbers and dates on the index table | `NumberRangeFilter`, `DateRangeFilter` | +| `created_at` | `DateRangeFilter` | +| `deleted_at` | `TrashedFilter` | + +The sorts are the sortable columns of the index table, and `created_at` and `updated_at`. The default sort is the newest first. + +# Bulk actions + +`ProductBulkActions` (javaabu/bulk-actions) runs on the selected rows, or on every row matching the filters: + +```php +protected function actions(): array +{ + return [ + DeleteAction::make()->can('delete_products'), + RestoreAction::make()->can('delete_products'), + ForceDeleteAction::make()->can('force_delete_products'), + ]; +} +``` + +Restore and force delete are added only for soft-deleting tables. Add your own actions to the array: see [javaabu/bulk-actions](https://github.com/Javaabu/bulk-actions). + +# Tables the generators ignore + +Framework and package tables (`migrations`, `jobs`, `permissions`, `media` and others) are never relations. The list is `generators.ignored_tables`. diff --git a/docs/basic-usage/rolling-back-changes.md b/docs/basic-usage/rolling-back-changes.md index 7fc00c3..6f13807 100644 --- a/docs/basic-usage/rolling-back-changes.md +++ b/docs/basic-usage/rolling-back-changes.md @@ -1,16 +1,12 @@ --- title: Rolling back changes -sidebar_position: 4 +sidebar_position: 10 --- -If you want to roll back the changes made by the generator, you can run the following command. +To undo what a generator wrote, commit your work first, then run: ```bash php artisan generate:rollback ``` -:::danger - -If you run this command you will lose all uncommitted changes. So make sure any changes you want to keep are commited before running this command. - -::: +It runs `git reset`, `git checkout .` and `git clean -fd`: **every uncommitted change is lost**, not only the generated files. It asks before it does; `--force` skips the question. diff --git a/docs/basic-usage/user-types.md b/docs/basic-usage/user-types.md new file mode 100644 index 0000000..25a2f1a --- /dev/null +++ b/docs/basic-usage/user-types.md @@ -0,0 +1,100 @@ +--- +title: User types +sidebar_position: 9 +--- + +`generate:auth` creates a new kind of user with [javaabu/auth](https://github.com/Javaabu/auth) v3: customers, vendors, staff. Each has its own model, guard, password broker, login and account pages, and permissions. You also get the admin pages that manage its users. + +# 1. The table + +Write a migration with javaabu/auth's user columns, or let the command write it: + +```bash +php artisan generate:auth customers --migration --create +``` + +```php +Schema::create('customers', function (Blueprint $table) { + // name, email, password, account status, two-factor and soft deletes + UserSchema::columns($table); + + // the columns of your user type + $table->string('phone')->nullable(); +}); +``` + +Add your columns and migrate. + +# 2. The user type + +```bash +php artisan generate:auth customers --create +``` + +| Option | Default | +| --- | --- | +| `--type` | The singular of the table: `customer` | +| `--guard` | The type's name: `customer` | + +All the options of `generate:all` work too: `--views`, `--index`, `--form`, `--except` and so on. Skip the auth parts with `--except=config,routes`. + +What's generated: + +- **`app/Models/Customer.php`** extends `Javaabu\Auth\Models\User`. That gives account status, login lockout, email verification, two-factor authentication, passkeys and soft deletes. +- **`config/auth.php`** gets the `customer` session guard, the `customers` user provider and the `customers` password broker (on the `password_reset_tokens` table). +- **`AppServiceProvider::boot()`** registers the type: + + ```php + UserTypes::register('customer', [ + 'model' => Customer::class, + 'guard' => 'customer', + 'passwords' => 'customers', + ]); + ``` + + Add its other options there: `home`, `features`, `two_factor`, `views`, `layouts`. See [javaabu/auth's user types](https://github.com/Javaabu/auth). +- **`routes/web.php`** gets the login, registration, password reset, email verification and account pages: + + ```php + Route::authRoutes('customer', ['prefix' => 'customer']); // customer.login, customer.register, ... + ``` + + javaabu/auth ships the controllers and the Blade and Inertia pages, so none are generated. +- **The admin pages of customers**, like `generate:all`: + - The form has the name, email, password (required for new customers, kept when left empty), account status and your columns. + - The columns javaabu/auth manages (login attempts, two-factor secrets, tokens) are left out (`generators.auth.skip_columns`). + - The admins' account actions are added to the admin routes: `Route::userAccountActions('customer', ['prefix' => 'customers'])` (unlock, reset two-factor, require a new password, log out). +- **Permissions:** + - `view_customers`, `edit_customers`, `delete_customers` and `force_delete_customers`. + - javaabu/auth adds `unlock_customers` and the other account permissions. + +Protect the customers' own pages with the type's middleware, not `auth:customer`: + +```php +Route::middleware('auth.type:customer')->prefix('customer')->name('customer.')->group(function () { + Route::get('/', CustomerHomeController::class)->name('home'); +}); +``` + +# Admins as a user type + +The admin area itself can be a user type. Point the generators at it first, so every resource, the admins' own pages included, uses its guard: + +```php +// config/generators.php +'admin' => [ + 'guard' => 'web_admin', + 'user_model' => App\Models\Admin::class, + 'middleware' => ['web', 'auth.type:admin'], +], +``` + +Then generate it: + +```bash +php artisan generate:auth admins --type=admin --guard=web_admin --migration --create # the migration +php artisan migrate +php artisan generate:auth admins --type=admin --guard=web_admin --create +``` + +As `App\Models\Admin` is the admins' model, it gets javaabu/permissions' `HasRoles` trait, and its factory makes approved, verified admins. diff --git a/docs/basic-usage/using-enum-classes.md b/docs/basic-usage/using-enum-classes.md deleted file mode 100644 index 9596231..0000000 --- a/docs/basic-usage/using-enum-classes.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: Using Enum classes -sidebar_position: 4 ---- - -If you want to use a PHP Enum class for a column instead of a MySQL `enum` or `set` type, you can indicate to the generator that the column should use an Enum class by adding a comment to the column in the format `enum:`: - -```php -use Illuminate\Database\Migrations\Migration; -use Illuminate\Database\Schema\Blueprint; -use Illuminate\Support\Facades\Schema; -use App\Enums\OrderStatuses; - -class CreateOrdersTable extends Migration -{ - /** - * Run the migrations. - * - * @return void - */ - public function up() - { - Schema::create('orders', function (Blueprint $table) { - $table->id(); - $table->string('status')->index()->comment('enum:' . OrderStatuses::class); - $table->timestamps(); - }); - } -} -``` - -Now the generator will utilize the `OrderStatuses` Enum class when generating all the relevant code for the `status` column. diff --git a/docs/basic-usage/using-generators.md b/docs/basic-usage/using-generators.md deleted file mode 100644 index d0a8099..0000000 --- a/docs/basic-usage/using-generators.md +++ /dev/null @@ -1,157 +0,0 @@ ---- -title: Using generators -sidebar_position: 1 ---- - -Let's say you have migrated a `products` table with the following schema: - -```php -Schema::create('products', function (Blueprint $table) { - $table->id(); - $table->string('name'); - $table->string('address'); - $table->string('slug')->unique(); - $table->text('description')->nullable(); - $table->unsignedDecimal('price', 14, 2); - $table->unsignedInteger('stock'); - $table->boolean('on_sale')->default(false); - $table->json('features'); - $table->dateTime('published_at'); - $table->timestamp('expire_at'); - $table->date('released_on'); - $table->time('sale_time'); - $table->enum('status', ['draft', 'published']); - $table->foreignId('category_id')->nullable()->constrained()->nullOnDelete(); - $table->year('manufactured_year'); - $table->timestamps(); - $table->softDeletes(); -}); -``` - -# Generating for a whole table - -Now you can run any available generator for the table: - -```bash -php artisan generate:factory products -``` - -The generator will inspect your schema and output relevant code using all your table columns: - -```php -Schema-based factory for table "products" have been generated! -Copy & paste these to your factory class: - - */ -class ProductFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'name' => fake()->text(fake()->numberBetween(5, 255)), - 'address' => fake()->address(), - 'slug' => fake()->unique()->slug(), - 'description' => fake()->optional()->sentences(3, true), - 'price' => fake()->randomFloat(2, 0, 999999999999), - 'stock' => fake()->numberBetween(0, 4294967295), - 'on_sale' => fake()->boolean(), - 'features' => fake()->words(), - 'published_at' => fake()->dateTime()?->format('Y-m-d H:i'), - 'expire_at' => fake()->dateTime()?->format('Y-m-d H:i'), - 'released_on' => fake()->date(), - 'sale_time' => fake()->time(), - 'status' => fake()->randomElement(['draft', 'published']), - 'manufactured_year' => fake()->year(2100), - ]; - } - - public function withCategory(): ProductFactory - { - return $this->state(function (array $attributes) { - return [ - 'category_id' => fake()->passThrough(random_id_or_generate(\App\Models\Category::class, 'id', generate: true)), - ]; - }); - } -} - -``` - -# Generating for specific columns - -If you want to generate code using only a subset of your table's columns, you can use the `--columns` option to specify the columns: - -```bash -php artisan generate:factory products --columns name,address -``` - -Now the code will be generated only using those columns: - -```php -Schema-based factory for table "products" have been generated! -Copy & paste these to your factory class: - - */ -class ProductFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'name' => fake()->text(fake()->numberBetween(5, 255)), - 'address' => fake()->address(), - ]; - } -} -``` - -# Generate files instead of outputting code - -By default, all generators will output the generated code to the console. You can add a `--create` or `-c` flag, which will create or append to the relevant files for you. - -```bash -php artisan generate:factory products --create -``` - -When using the `--create` flag, if any of the generated files already exists, the generator will refuse to overwrite the file. To force an overwrite, you can use the `--force` or `-f` flag. - -```bash -php artisan generate:factory products --create --force -``` - -By default, the generator will figure out the relevant directory to put the generated file. If you want the files to be generated in a custom directory, you can use the `--path` option to specify a custom path relative to your project root. - -```bash -php artisan generate:factory products --create --path database\\seeders -``` - -# Always skip columns - -To always skip columns add it in the config file, under `skip_columns` parameter. - -```php -'skip_columns' => ['whatever', 'some_other_column'], -``` diff --git a/docs/changelog.md b/docs/changelog.md index 4207c71..f618286 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,4 +3,4 @@ title: Changelog sidebar_position: 1.7 --- -All notable changes to this package are documented on [GitHub](https://github.com/Javaabu/generators/blob/main/CHANGELOG.md) +All notable changes to this package are documented on [GitHub](https://github.com/Javaabu/generators/blob/master/CHANGELOG.md) diff --git a/docs/installation-and-setup.md b/docs/installation-and-setup.md index 6520f8c..954b82f 100644 --- a/docs/installation-and-setup.md +++ b/docs/installation-and-setup.md @@ -3,78 +3,37 @@ title: Installation & Setup sidebar_position: 1.2 --- -You can install the package via composer: +# Installing -```bash -composer require javaabu/generators -``` - -# Publishing the config file - -Publishing the config file is optional: +The generators are a development tool: ```bash -php artisan vendor:publish --provider="Javaabu\Generators\GeneratorsServiceProvider" --tag="generators-config" +composer require javaabu/generators --dev ``` -This is the default content of the config file: - -```php - env('GENERATORS_TINYINT1_TO_BOOL', true), +The generated code expects the packages it uses (see [Requirements](requirements.md)) and these few things in the app: +1. **A model for the admins** with javaabu/permissions' `HasRoles` trait and a factory. The default is `App\Models\User` on the default guard; set [`generators.admin`](advanced-usage/configuration.md) for another model or guard, such as a javaabu/auth `admin` user type. +2. **A layout** for the Blade pages: `resources/views/layouts/admin.blade.php` with a `content` section, or set `generators.blade.layout` and `generators.blade.section`. It shows `session('status')`, where the controllers put their messages. +3. **For Inertia React pages**: + - inertia-laravel, javaabu/manifest's Vite plugin, and the `FormsProvider` in `resources/js/app.tsx`, as in [@javaabu/forms' setup](https://github.com/Javaabu/react-forms) + - `__()` from `@javaabu/i18n`, through [javaabu/js-localization](https://github.com/Javaabu/js-localization) + - pages resolved from `resources/js/pages` with `resolvePage(name, import.meta.glob("./pages/**/*.tsx"), { base: "./pages/" })` +4. **The routes file**: the generators write `routes/admin.php` the first time, and load it from `routes/web.php`. +5. **Permissions**: run `php artisan permissions:sync` after generating, and on every deploy. - /* - |-------------------------------------------------------------------------- - | Which columns to always skip - |-------------------------------------------------------------------------- - | - */ - 'skip_columns' => ['created_at', 'updated_at', 'deleted_at'], +# Publishing - /* - |-------------------------------------------------------------------------- - | Which columns to always skip for auth - |-------------------------------------------------------------------------- - | - */ - 'auth_skip_columns' => [ - 'name', - 'email', - 'email_verified_at', - 'password', - 'remember_token', - 'last_login_at', - 'login_attempts', - 'require_password_update', - 'status', - 'new_email' - ], - - /* - |-------------------------------------------------------------------------- - | Which icon provider to use - |-------------------------------------------------------------------------- - | - */ - 'icon_provider' => \Javaabu\Generators\IconProviders\MaterialDesignIconicProvider::class, - - /* - |-------------------------------------------------------------------------- - | Icon prefix to use for sidebar menu items - |-------------------------------------------------------------------------- - | - */ - 'sidebar_icon_prefix' => 'zmdi-', -]; +```bash +# the config: views, the admin area, namespaces, schema options +php artisan vendor:publish --tag=generators-config +# the stubs, to change what the generators write +php artisan vendor:publish --tag=generators-stubs ``` + +See [Configuration](advanced-usage/configuration.md) and [Customising stubs](advanced-usage/customising-stubs.md). diff --git a/docs/intro.md b/docs/intro.md index e5db9c3..90922ea 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -5,80 +5,45 @@ sidebar_position: 1.0 # Generators -[Generators](https://github.com/Javaabu/generators) provide Laravel generators on steroids based on database schemas. -For example, if you have a `products` table with the following schema: - -```php -Schema::create('products', function (Blueprint $table) { - $table->id(); - $table->string('name'); - $table->string('address'); - $table->string('slug')->unique(); - $table->text('description')->nullable(); - $table->unsignedDecimal('price', 14, 2); - $table->unsignedInteger('stock'); - $table->boolean('on_sale')->default(false); - $table->json('features'); - $table->dateTime('published_at'); - $table->timestamp('expire_at'); - $table->date('released_on'); - $table->time('sale_time'); - $table->enum('status', ['draft', 'published']); - $table->foreignId('category_id')->nullable()->constrained()->nullOnDelete(); - $table->year('manufactured_year'); - $table->timestamps(); - $table->softDeletes(); -}); -``` - -and you run the artisan command `php artisan generate:factory products --create` it will output the code for a `ProductFactory` like below: - -```php - - */ -class ProductFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'name' => fake()->text(fake()->numberBetween(5, 255)), - 'address' => fake()->address(), - 'slug' => fake()->unique()->slug(), - 'description' => fake()->optional()->sentences(3, true), - 'price' => fake()->randomFloat(2, 0, 999999999999), - 'stock' => fake()->numberBetween(0, 4294967295), - 'on_sale' => fake()->boolean(), - 'features' => fake()->words(), - 'published_at' => fake()->dateTime()?->format('Y-m-d H:i'), - 'expire_at' => fake()->dateTime()?->format('Y-m-d H:i'), - 'released_on' => fake()->date(), - 'sale_time' => fake()->time(), - 'status' => fake()->randomElement(['draft', 'published']), - 'manufactured_year' => fake()->year(2100), - ]; - } - - public function withCategory(): ProductFactory - { - return $this->state(function (array $attributes) { - return [ - 'category_id' => fake()->passThrough(random_id_or_generate(\App\Models\Category::class, 'id', generate: true)), - ]; - }); - } -} +[javaabu/generators](https://github.com/Javaabu/generators) reads a database table and writes a complete, tested admin resource for it, built on the Javaabu next-gen packages. You design the migration; the generators write the rest. +```bash +php artisan generate:all products --create ``` +For a `products` table with a category, tags (through a `product_tag` pivot table), an enum `status` and soft deletes, that writes: + +| File | What it uses | +| --- | --- | +| `app/Models/Product.php` | Fillable fields, casts, `category()`, `tags()` and the other relations found in the schema, `SoftDeletes` | +| `database/factories/ProductFactory.php` | A value for every column, related records for foreign keys | +| `app/Policies/ProductPolicy.php` | [javaabu/permissions](https://github.com/Javaabu/permissions)' `ResourcePolicy` | +| `app/Providers/AppServiceProvider.php` | `Permissions::resource('products')`: `view_products`, `edit_products`, `delete_products`, `force_delete_products` | +| `app/Filters/ProductFilters.php` | [javaabu/filters](https://github.com/Javaabu/filters): a search over the text columns, selects for the status, category and tags, ranges, trashed records, sorts | +| `app/BulkActions/ProductBulkActions.php` | [javaabu/bulk-actions](https://github.com/Javaabu/bulk-actions): delete, restore and permanently delete the selected or filtered products | +| `app/Stencil/ProductForm.php` | A [javaabu/stencil](https://github.com/Javaabu/stencil) form: one field per column, and the validation rules | +| `app/Http/Controllers/Admin/ProductsController.php` | Index, create, show, edit, delete, restore and permanently delete, authorized by the policy | +| `routes/admin.php` | `Route::resource()`, the restore and delete routes, `Route::bulkActions()` | +| `resources/views/admin/products/*.blade.php` | Blade pages with [javaabu/forms](https://github.com/Javaabu/forms) | +| or `resources/js/pages/admin/products/*.tsx` | Inertia React pages with [@javaabu/forms](https://github.com/Javaabu/react-forms) and a JSON resource | +| `tests/Feature/Admin/ProductsControllerTest.php` | Feature tests of every page, validation, authorization and the bulk actions, which pass as they are | +| `lang/dv.json` | Dhivehi for the generic phrases of the pages | + +The generators also: + +- nest resources under a parent (`/admin/categories/{category}/products`) +- add [javaabu/mediapicker](https://github.com/Javaabu/mediapicker) media collections +- make models translatable with [javaabu/translatable](https://github.com/Javaabu/translatable) +- create [javaabu/auth](https://github.com/Javaabu/auth) user types with their own guard, routes and permissions +- let you choose which fields appear on the index table, the form and the show page + +The generated code is yours: it is plain Laravel code that follows the Javaabu coding standards and passes Pint. Edit it as you like. + +Every generated file is checked in this package's CI. A whole admin is generated into a Laravel app, its tests run, and the React pages are type-checked. + +# What next + +- [Requirements](requirements.md) and [installation](installation-and-setup.md) +- [Generating a resource](basic-usage/generating-a-resource.md): a walkthrough +- [Available generators](basic-usage/available-generators.md): every command +- [Upgrading from v1](upgrading.md) diff --git a/docs/limitations/_category_.json b/docs/limitations/_category_.json deleted file mode 100644 index 4510004..0000000 --- a/docs/limitations/_category_.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "position": 1.4, - "label": "Limitations", - "collapsible": true, - "collapsed": false, - "link": { - "type": "generated-index", - "slug": "/generators/_categories/limitations", - } -} diff --git a/docs/limitations/supported-drivers.md b/docs/limitations/supported-drivers.md deleted file mode 100644 index 89c1b67..0000000 --- a/docs/limitations/supported-drivers.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Supported drivers -sidebar_position: 1 ---- - -Currently, the only supported database driver is `MySQL` diff --git a/docs/limitations/supported-field-types.md b/docs/limitations/supported-field-types.md deleted file mode 100644 index 73e98e1..0000000 --- a/docs/limitations/supported-field-types.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: Supported Field Types -sidebar_position: 2 ---- - -This package works by mapping database schema column types to a custom `Field` class. The supported field types and their corresponding database column types are given below. - -| Field Type | Column Types | -|-----------------|-------------------------------------------------------------------------------| -| BooleanField | `tinyint(1)` | -| DateField | `date` | -| DateTimeField | `datetime`, `timestamp` | -| DecimalField | `double`, `decimal`, `dec`, `float` | -| EnumField | `enum`, `set`, any column that has a comment in the format `enum:` | -| ForeignKeyField | `Foreign` | -| IntegerField | `tinyint`, `smallint`, `mediumint`, `int`, `bigint` | -| JsonField | `json` | -| StringField | `varchar`, `char` | -| TextField | `text`, `tinytext`, `mediumtext`, `longtext` | -| TimeField | `time` | -| YearField | `year` | - -Currently, morphs and pivots are not supported. Any unsupported column type will simply be skipped by the generators. diff --git a/docs/metadata.json b/docs/metadata.json index 10ca020..e62797e 100644 --- a/docs/metadata.json +++ b/docs/metadata.json @@ -1,5 +1,5 @@ { "name": "Generators", - "description": "Laravel generators on steroids", - "version": "1" + "description": "Generate a complete, tested Laravel admin resource from a database table, built on the Javaabu next-gen packages", + "version": "2" } diff --git a/docs/requirements.md b/docs/requirements.md index 73bd6c0..0810ec6 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -3,7 +3,23 @@ title: Requirements sidebar_position: 1.1 --- -This package requires the following: +- PHP 8.4 or 8.5 +- Laravel 12 or 13 +- MySQL 8, MariaDB 10.11+, PostgreSQL 14+ or SQLite 3.35+ (see [Databases and column types](advanced-usage/databases-and-column-types.md)) -- Laravel 9.0 or higher -- PHP 8.1 or higher +The generators themselves only need Laravel. The code they write uses these packages, which your app installs: + +| Package | For | +| --- | --- | +| [javaabu/forms](https://github.com/Javaabu/forms) v2 | Blade pages | +| [javaabu/stencil](https://github.com/Javaabu/stencil) | Forms and their validation | +| [javaabu/filters](https://github.com/Javaabu/filters) | Index filters and sorts | +| [javaabu/bulk-actions](https://github.com/Javaabu/bulk-actions) | Bulk actions | +| [javaabu/permissions](https://github.com/Javaabu/permissions) v3 | Policies and permissions | +| [inertiajs/inertia-laravel](https://github.com/inertiajs/inertia-laravel), [@javaabu/forms](https://github.com/Javaabu/react-forms) and [@javaabu/i18n](https://github.com/Javaabu/i18n) | Inertia React pages (`--views=inertia`) | +| [javaabu/mediapicker](https://github.com/Javaabu/mediapicker) | Media collections (`--media`) | +| [javaabu/translatable](https://github.com/Javaabu/translatable) v3 | Translatable models (optional) | +| [javaabu/auth](https://github.com/Javaabu/auth) v3 | User types (`generate:auth`) | +| [javaabu/exports](https://github.com/Javaabu/exports) v2 | Exports (`generate:export`) | + +The [Javaabu Laravel skeletons](https://github.com/Javaabu) come with all of them. diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..d9b2aaa --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,72 @@ +--- +title: Upgrading from v1 +sidebar_position: 1.65 +--- + +v2 writes different code, for the Javaabu next-gen packages, so read this before regenerating anything. Code that v1 generated keeps working: v2 doesn't change files that exist unless you pass `--force`. + +# Requirements + +- PHP 8.4+ and Laravel 12+ (v1: PHP 8.1+, Laravel 9+). +- `doctrine/dbal` is no longer needed. +- The schema is read with Laravel's schema builder, so MySQL, MariaDB, PostgreSQL and SQLite all work (v1: MySQL only). + +# The generated code + +| v1 | v2 | +| --- | --- | +| Models implement `AdminModel` with `IsAdminModel`, `LogsActivity` and `$searchable` (javaabu/helpers, javaabu/activitylog) | Plain models: fillable, casts, the relations found in the schema | +| Policies check `view_x`, `edit_x`, `delete_x` | Policies extend javaabu/permissions' `ResourcePolicy` | +| Permissions in `database/seeders/PermissionsSeeder.php` | `Permissions::resource('products')` in `AppServiceProvider::boot()`, written by `php artisan permissions:sync` | +| `ProductsRequest` with the rules | A javaabu/stencil form, `App\Stencil\ProductForm`, whose fields give the rules | +| `HasOrderbys`, `search`, `date_field`/`date_from`/`date_to` in the controller | `App\Filters\ProductFilters` (javaabu/filters) | +| A `bulk()` action in the controller | `App\BulkActions\ProductBulkActions` (javaabu/bulk-actions) and `Route::bulkActions()` | +| Material Admin Blade views with javaabu/forms v1 | javaabu/forms v2 Blade views, or Inertia React pages (`--views=inertia`) | +| `ProductsExport` with maatwebsite/excel | `generate:export` with javaabu/exports v2 (not part of `generate:all`) | +| Tests extend the skeleton's `AdminTestCase` (`actingAsAdmin`, `adminGet`) | Tests extend `Tests\TestCase`, log in with their own `actingAsAdmin()`, and pass as generated | +| Sidebar menu links, icons | Not generated | + +# Commands + +| v1 | v2 | +| --- | --- | +| `generate:all`, `model`, `factory`, `policy`, `permissions`, `controller`, `routes`, `views`, `test`, `export`, `rollback` | The same names | +| `generate:request` | Removed: the stencil form validates. Use `generate:form` | +| `generate:api`, `generate:api_controller`, `generate:api_test` | Removed | +| `generate:auth_model`, `auth_factory`, `auth_config`, `auth_policy`, `auth_permissions`, `auth_request`, `auth_password_resets`, `auth_controller` and the `auth_*_controller` commands | One `generate:auth {table}`. javaabu/auth v3 ships the login, registration, password and account controllers and pages, so they aren't generated. See [User types](basic-usage/user-types.md) | +| | New: `generate:filters`, `generate:bulk-actions`, `generate:form` | + +# Options + +| v1 | v2 | +| --- | --- | +| `--columns`, `--create`, `--force` | The same | +| `--except` (`generate:all`) | The same; the parts are `model`, `factory`, `policy`, `permissions`, `filters`, `bulk-actions`, `form`, `controller`, `routes`, `views`, `test`, `translations` | +| `--path` | Removed: set `generators.namespaces` | +| `--auth_name` | `--type` (`generate:auth`) | +| | New: `--index`, `--form`, `--show`, `--interactive`, `--views`, `--parent`, `--media`, `--translatable`; `--guard` and `--migration` for `generate:auth` | + +# Config + +Publish the new config (`php artisan vendor:publish --tag=generators-config --force`) and copy over what you changed: + +| v1 | v2 | +| --- | --- | +| `tinyint1_to_bool`, `skip_columns` | The same | +| `auth_skip_columns` | `auth.skip_columns` | +| `icon_provider`, `sidebar_icon_prefix` | Removed | +| | New: `views`, `connection`, `admin.*`, `blade.*`, `namespaces.*`, `enums`, `name_fields`, `index_columns`, `ignored_tables`, `translatable` | + +# Stubs + +Every stub is new. Delete `stubs/vendor/generators` and publish again (`php artisan vendor:publish --tag=generators-stubs`) before customising. + +# The routes file + +v1 added routes above `// Generator Routes - DONOT REMOVE` in `routes/admin.php`. v2 adds them above `// generators: routes`, and creates `routes/admin.php` (loaded from `routes/web.php`) when it doesn't exist. In a v1 app, put the new marker where the old one is, inside your admin route group. Without a marker, v2 adds the routes at the end of the file. + +# Enum classes + +- **Comments:** `->comment('enum:App\Enums\Statuses')` still works on MySQL and PostgreSQL. +- **javaabu/schema:** its `nativeEnum()` isn't needed. +- **SQLite**, which keeps no comments: name the classes in `generators.enums`. See [Enums](basic-usage/enums.md). diff --git a/lang/dv/generators.php b/lang/dv/generators.php new file mode 100644 index 0000000..7473391 --- /dev/null +++ b/lang/dv/generators.php @@ -0,0 +1,26 @@ + 'މި ކޮމާންޑަށް އެއްފަހަރާ ދެވޭނީ އެއް ޓޭބަލެވެ. ދެއްވީ: :tables', + 'no_table' => '":table" ޓޭބަލެއް ނުފެނުނު.', + 'many_to_many' => 'ގިނަ-ގިނަ', + 'prompt_parent' => 'މި ރިސޯސް އެހެން ރިސޯހެއްގެ ދަށަށް ލާންވީތޯ؟', + 'not_nested' => 'ދަށަށް ނުލާ', + 'prompt_index' => 'އިންޑެކްސް ޓޭބަލުގައި ހިމަނާނީ ކޮން ފީލްޑުތަކެއް؟', + 'prompt_form' => 'ފޯމުގައި ހިމަނާނީ ކޮން ފީލްޑުތަކެއް؟', + 'prompt_show' => 'ތަފްސީލު ޞަފްޙާގައި ހިމަނާނީ ކޮން ފީލްޑުތަކެއް؟', + 'prompt_media' => 'މީޑިއާ ކަލެކްޝަންތައް (ނެތްނަމަ ހުސްކޮށް ދޫކޮށްލާ)', + 'prompt_media_hint' => 'name:type[:multiple]، ވައްތަރުތައް: image, video, audio, document, archive', + 'prompt_views' => 'ކޮން ވިއުތަކެއް؟', + 'added_to' => 'ފައިލަށް އިތުރުކުރެވުނު', + 'created' => 'އުފެއްދުނު', + 'updated' => 'ބަދަލުކުރެވުނު', + 'unchanged' => 'ބަދަލެއް ނުވި', + 'exists' => 'ކުރިން ހުރި، ދޫކޮށްލެވުނު (މަތިން ލިޔުމަށް --force ބޭނުންކުރޭ)', + 'overwritten' => 'މަތިން ލިޔެވުނު', + 'next_steps' => 'ކުރިއަށް ކުރަންޖެހޭ ކަންކަން', + 'rolled_back' => 'ޖެނެރޭޓަރުން ގެނައި ބަދަލުތައް އަނބުރާ ގެނެވިއްޖެ.', + 'rollback_warning' => 'މި ކޮމާންޑު ހިންގައިފިނަމަ، ކޮމިޓް ނުކުރާ ހުރިހާ ބަދަލެއް ގެއްލިގެންދާނެ!', + 'auth_migrate' => ':table ގެ މައިގްރޭޝަން ލިޔެވިއްޖެ. ތިޔަބޭފުޅާގެ ކޮލަމްތައް އިތުރުކޮށް، php artisan migrate ހިންގައި، ޔޫޒަރ ޓައިޕް ހެދުމަށް މި ކޮމާންޑު އަލުން ހިންގާ.', + 'pivot_table' => ':table އަކީ :first އާއި :second ގުޅުވާ ޕިވޮޓް ޓޭބަލެވެ: އެ ދެ ޓޭބަލް ޖެނެރޭޓްކުރާ، އޭރުން ގިނަ-ގިނަ ގުޅުމާއި ގިނަ އެއްޗެހި ހޮވޭ ސެލެކްޓެއް ލިބޭނެ.', +]; diff --git a/lang/dv/phrases.php b/lang/dv/phrases.php new file mode 100644 index 0000000..8233dba --- /dev/null +++ b/lang/dv/phrases.php @@ -0,0 +1,17 @@ + 'ހޯދާ', + 'Created at' => 'އުފެއްދި ތާރީޚް', + 'Updated at' => 'ބަދަލުކުރި ތާރީޚް', + 'Edit' => 'ބަދަލުކުރޭ', + 'View' => 'ބަލާ', + 'Delete' => 'ފޮހެލާ', + 'Restore' => 'އަނބުރާ ގެނޭ', + 'Delete permanently' => 'ދާއިމީކޮށް ފޮހެލާ', + 'Media' => 'މީޑިއާ', + 'Breadcrumb' => 'ނެވިގޭޝަން', + 'ID' => 'އައިޑީ', +]; diff --git a/lang/en/generators.php b/lang/en/generators.php new file mode 100644 index 0000000..678a7c3 --- /dev/null +++ b/lang/en/generators.php @@ -0,0 +1,26 @@ + 'The command takes one table at a time, you gave: :tables', + 'no_table' => 'Table ":table" not found.', + 'many_to_many' => 'many-to-many', + 'prompt_parent' => 'Nest the resource under a parent?', + 'not_nested' => 'Not nested', + 'prompt_index' => 'Which fields go in the index table?', + 'prompt_form' => 'Which fields go in the form?', + 'prompt_show' => 'Which fields go on the show page?', + 'prompt_media' => 'Media collections (leave empty for none)', + 'prompt_media_hint' => 'name:type[:multiple], types: image, video, audio, document, archive', + 'prompt_views' => 'Which views?', + 'added_to' => 'added to the file', + 'created' => 'created', + 'updated' => 'updated', + 'unchanged' => 'unchanged', + 'exists' => 'exists, skipped (use --force to overwrite)', + 'overwritten' => 'overwritten', + 'next_steps' => 'Next steps', + 'rolled_back' => 'Generator changes rolled back.', + 'rollback_warning' => 'If you run this command, you will lose all uncommitted changes!', + 'auth_migrate' => 'The migration for :table is written. Add your columns, run php artisan migrate, then run this command again to generate the user type.', + 'pivot_table' => ':table is the pivot table of :first and :second: generate those, and they get a many-to-many relation and a multiple select.', +]; diff --git a/lang/en/phrases.php b/lang/en/phrases.php new file mode 100644 index 0000000..aea3954 --- /dev/null +++ b/lang/en/phrases.php @@ -0,0 +1,16 @@ + 'Search', + 'Created at' => 'Created at', + 'Updated at' => 'Updated at', + 'Edit' => 'Edit', + 'View' => 'View', + 'Delete' => 'Delete', + 'Restore' => 'Restore', + 'Delete permanently' => 'Delete permanently', + 'Media' => 'Media', + 'Breadcrumb' => 'Breadcrumb', + 'ID' => 'ID', +]; diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..32f1fd9 --- /dev/null +++ b/llms.txt @@ -0,0 +1,43 @@ +# javaabu/generators + +> Generates a complete, tested Laravel admin resource from a database table: model (with the relations found in the schema), factory, javaabu/permissions policy and permissions, javaabu/filters filters, javaabu/bulk-actions bulk actions, a javaabu/stencil form, controller, routes, Blade (javaabu/forms v2) or Inertia React (@javaabu/forms) pages, and feature tests that pass as generated. Also nests resources, adds javaabu/mediapicker media, makes models translatable (javaabu/translatable) and creates javaabu/auth user types. Reads MySQL, MariaDB, PostgreSQL and SQLite schemas. + +Paths are relative to this file. In a project, that's vendor/javaabu/generators/. + +## Rules + +- Write and run the migration first: the generated code comes from the schema (types, nullability, defaults, unique, foreign keys, soft deletes, enums). Generate the tables it refers to before it. +- `php artisan generate:all {table}` prints the code; `--create` writes it; existing files are skipped unless `--force`. Run `php artisan permissions:sync` after generating. +- Choose fields with `--index=`, `--form=`, `--show=` (columns, and many-to-many relations by name), or `--interactive`. Pages: `--views=blade|inertia`. Nesting: `--parent=`. Media: `--media=name[:type][:multiple]`. Skip parts with `--except=`. +- Pivot tables aren't resources: generating the two tables gives both a many-to-many relation and a multiple select. +- New user types use `generate:auth {table}` (javaabu/auth v3 ships the auth controllers and pages; never write them). `--migration` writes the table's migration with `UserSchema::columns()`. +- The stencil form validates: don't add a FormRequest; change rules on the form fields. Controllers authorize with the policy (`Gate::authorize()`). +- Name enum classes in a column comment (`enum:App\Enums\X`, MySQL/PostgreSQL) or in `config('generators.enums')` (every driver). +- The admin area's guard, user model, routes file, prefixes, layout and namespaces are config (`generators.admin.*`, `generators.blade.*`, `generators.namespaces.*`); nothing assumes the web guard or App\Models\User. +- Customize what's written by publishing the stubs (`--tag=generators-stubs`) or extending `GenerateCommand` and `Generator`. + +## Docs + +- [Introduction](docs/intro.md): what the generators write +- [Requirements](docs/requirements.md): PHP, Laravel, databases, the packages the generated code uses +- [Installation & Setup](docs/installation-and-setup.md): installing, getting the app ready (admin model, layout, Inertia, routes, permissions), publishing +- [AI assistants](docs/ai-assistants.md): Laravel Boost and these docs +- [Generating a resource](docs/basic-usage/generating-a-resource.md): the walkthrough, printing and writing, merged files, skipping parts, every option +- [Available generators](docs/basic-usage/available-generators.md): every command, and what each part writes +- [Choosing fields](docs/basic-usage/choosing-fields.md): the defaults, --index, --form, --show, --columns, the prompts +- [Relations, filters and bulk actions](docs/basic-usage/relations.md): belongsTo, hasMany, pivot tables, the filters and bulk actions written for them +- [Enums](docs/basic-usage/enums.md): database enums, enum classes from comments or config +- [Blade and Inertia React](docs/basic-usage/blade-and-inertia.md): the pages, the JSON resource, the app setup +- [Nested resources](docs/basic-usage/nested-resources.md): --parent, scoped routes, controllers, bulk actions, tests +- [Media](docs/basic-usage/media.md): --media, attachment collections, media pickers, the media library routes +- [User types](docs/basic-usage/user-types.md): generate:auth, the guard, the user type, auth routes, the admin pages, admins as a user type +- [Rolling back changes](docs/basic-usage/rolling-back-changes.md): generate:rollback +- [Configuration](docs/advanced-usage/configuration.md): every config option +- [Customising stubs](docs/advanced-usage/customising-stubs.md): publishing, the stubs, placeholders +- [Translatable models](docs/advanced-usage/translatable.md): javaabu/translatable, optional, the columns, the forms and tests +- [Dhivehi and RTL](docs/advanced-usage/dhivehi-and-rtl.md): translations, RTL, Thaana, strings to review +- [Databases and column types](docs/advanced-usage/databases-and-column-types.md): how each column type is generated, what each database keeps +- [The generated tests](docs/advanced-usage/the-generated-tests.md): what they test, what they need, how this package tests them +- [Extending the generators](docs/advanced-usage/extending.md): the classes, your own generators and field types +- [Upgrading from v1](docs/upgrading.md): what changed in the generated code, commands, options and config +- [Questions and Security](docs/questions-and-security.md): getting help and reporting security issues diff --git a/package.json b/package.json new file mode 100644 index 0000000..8e993af --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "javaabu-generators-tooling", + "private": true, + "description": "Checks the Inertia React pages javaabu/generators writes: typechecks and formats the pages generated into the integration app (build/app).", + "license": "MIT", + "type": "module", + "packageManager": "pnpm@10.28.0", + "engines": { + "node": ">=20.19" + }, + "scripts": { + "typecheck:app": "tsc -p workbench/tsconfig.json", + "format:app": "prettier --check --config workbench/.prettierrc.json \"build/app/resources/js/**/*.{ts,tsx}\"" + }, + "devDependencies": { + "@inertiajs/react": "^2.3.0", + "@javaabu/forms": "github:Javaabu/react-forms#next-gen", + "@javaabu/i18n": "github:Javaabu/i18n#build-on-git-install", + "@types/node": "^26.6.6", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "prettier": "^3.9.10", + "react": "^19.3.1", + "react-dom": "^19.3.1", + "typescript": "~6.0.3" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "@javaabu/forms", + "@javaabu/i18n" + ] + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 61d9d10..071d495 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,13 +1,18 @@ - + cacheDirectory=".phpunit.cache" + failOnWarning="true" + failOnRisky="true"> - - tests + + tests/Unit + + + tests/Feature @@ -15,13 +20,10 @@ src - - - - - - - + + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..9e66cf1 --- /dev/null +++ b/pint.json @@ -0,0 +1,23 @@ +{ + "preset": "laravel", + "rules": { + "concat_space": { + "spacing": "one" + }, + "global_namespace_import": { + "import_classes": true, + "import_constants": false, + "import_functions": false + }, + "no_unused_imports": true, + "ordered_imports": { + "sort_algorithm": "alpha" + }, + "php_unit_method_casing": { + "case": "snake_case" + } + }, + "exclude": [ + "build" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..566faea --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,514 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@inertiajs/react': + specifier: ^2.3.0 + version: 2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@javaabu/forms': + specifier: github:Javaabu/react-forms#next-gen + version: https://codeload.github.com/Javaabu/react-forms/tar.gz/204e616e00c18a529902fa84f2cef168886f6061(@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@javaabu/i18n': + specifier: github:Javaabu/i18n#build-on-git-install + version: https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba(react@19.3.1) + '@types/node': + specifier: ^26.6.6 + version: 26.6.6 + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + prettier: + specifier: ^3.9.10 + version: 3.9.10 + react: + specifier: ^19.3.1 + version: 19.3.1 + react-dom: + specifier: ^19.3.1 + version: 19.3.1(react@19.3.1) + typescript: + specifier: ~6.0.3 + version: 6.0.3 + +packages: + + '@inertiajs/core@2.3.29': + resolution: {integrity: sha512-Cx3zibnKdsmEnXpsWhwmE9i4k5NFtK+4gtceL3Kb6RwYC1J0wi5y/EesNMkpdJLsAT2h4ugUvu9Ed9T1EUl0/A==} + + '@inertiajs/react@2.3.29': + resolution: {integrity: sha512-09SWBPxv8UrJdOWtQrEp1ro6jv1qJ8yOxH8eL+na9K/wbh7k9R0ALvAO3ofY52Cwuh7HMuSPgtRzk/q8E1k0Fg==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@javaabu/forms@https://codeload.github.com/Javaabu/react-forms/tar.gz/204e616e00c18a529902fa84f2cef168886f6061': + resolution: {tarball: https://codeload.github.com/Javaabu/react-forms/tar.gz/204e616e00c18a529902fa84f2cef168886f6061} + version: 0.0.0 + engines: {node: '>=20.19'} + peerDependencies: + '@inertiajs/react': ^2.0.0 || ^3.0.0 + '@javaabu/onu': ^0.2.1 + '@testing-library/react': ^16.0.0 + react: ^18.3.0 || ^19.0.0 + react-dom: ^18.3.0 || ^19.0.0 + peerDependenciesMeta: + '@inertiajs/react': + optional: true + '@javaabu/onu': + optional: true + '@testing-library/react': + optional: true + + '@javaabu/i18n@https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba': + resolution: {tarball: https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba} + version: 0.0.0 + engines: {node: '>=20.19'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + vue: ^3.3.0 + peerDependenciesMeta: + react: + optional: true + vue: + optional: true + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.27': + resolution: {integrity: sha512-VenMRv/eKNrspX9v3H4dbmjsrnCOYdYBG3kxXKk3dG6X+urBGcj2Prww3xw1U5K4FV/mE4h5GbWd0szEKf+9ew==} + + '@types/node@26.6.6': + resolution: {integrity: sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.21.0: + resolution: {integrity: sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + follow-redirects@1.16.2: + resolution: {integrity: sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + laravel-precognition@1.0.2: + resolution: {integrity: sha512-0H08JDdMWONrL/N314fvsO3FATJwGGlFKGkMF3nNmizVFJaWs17816iM+sX7Rp8d5hUjYCx6WLfsehSKfaTxjg==} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + prettier@3.9.10: + resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} + engines: {node: '>=14'} + hasBin: true + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + qs@6.17.1: + resolution: {integrity: sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==} + engines: {node: '>=0.6'} + + react-dom@19.3.1: + resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} + peerDependencies: + react: ^19.3.1 + + react@19.3.1: + resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} + engines: {node: '>=0.10.0'} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + +snapshots: + + '@inertiajs/core@2.3.29': + dependencies: + '@types/lodash-es': 4.17.12 + axios: 1.21.0 + laravel-precognition: 1.0.2 + lodash-es: 4.18.1 + qs: 6.17.1 + transitivePeerDependencies: + - debug + - supports-color + + '@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@inertiajs/core': 2.3.29 + '@types/lodash-es': 4.17.12 + laravel-precognition: 1.0.2 + lodash-es: 4.18.1 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - debug + - supports-color + + '@javaabu/forms@https://codeload.github.com/Javaabu/react-forms/tar.gz/204e616e00c18a529902fa84f2cef168886f6061(@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@javaabu/i18n': https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@inertiajs/react': 2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + transitivePeerDependencies: + - vue + + '@javaabu/i18n@https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba(react@19.3.1)': + optionalDependencies: + react: 19.3.1 + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.27 + + '@types/lodash@4.17.27': {} + + '@types/node@26.6.6': + dependencies: + undici-types: 8.9.0 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + asynckit@0.4.0: {} + + axios@1.21.0: + dependencies: + follow-redirects: 1.16.2 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + follow-redirects@1.16.2: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + laravel-precognition@1.0.2: + dependencies: + axios: 1.21.0 + lodash-es: 4.18.1 + transitivePeerDependencies: + - debug + - supports-color + + lodash-es@4.18.1: {} + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + ms@2.1.3: {} + + object-inspect@1.13.4: {} + + prettier@3.9.10: {} + + proxy-from-env@2.1.0: {} + + qs@6.17.1: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + react-dom@19.3.1(react@19.3.1): + dependencies: + react: 19.3.1 + scheduler: 0.28.0 + + react@19.3.1: {} + + scheduler@0.28.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + typescript@6.0.3: {} + + undici-types@8.9.0: {} diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php new file mode 100644 index 0000000..65a2721 --- /dev/null +++ b/resources/boost/guidelines/core.blade.php @@ -0,0 +1,14 @@ +{{-- + javaabu/generators guidelines for AI code assistants + + Maintained by: Javaabu (https://javaabu.com) +--}} + +## javaabu/generators + +- Scaffold admin CRUD with the generators instead of writing it by hand: write and run the migration, then `php artisan generate:all {table} --create`. It writes the model, factory, policy, permissions, filters, bulk actions, stencil form, controller, routes, Blade or Inertia React pages and passing feature tests. Run `php artisan permissions:sync` after. +- Generate a table's related tables first (its foreign keys' tables), so their models and factories exist. Pivot tables (`product_tag`) aren't resources: generate the two tables they join. +- Choose fields with `--index=`, `--form=`, `--show=` (many-to-many relations by name), pages with `--views=blade|inertia`, nesting with `--parent=`, media with `--media=name:type[:multiple]`. Use `generate:auth {table}` for a new javaabu/auth user type, never hand-written auth controllers. +- Without `--create` a command prints the code; existing files are skipped unless `--force`. Don't overwrite files the developer has edited. +- After generating, edit the generated code like any app code; keep the Javaabu codestyle and run the generated tests (`php artisan test --filter={Resource}ControllerTest`). +- Always activate the `javaabu-generators` skill when scaffolding resources, admin pages, CRUD, nested resources or user types, or when changing what the generators write. diff --git a/resources/boost/skills/javaabu-generators/SKILL.md b/resources/boost/skills/javaabu-generators/SKILL.md new file mode 100644 index 0000000..4d24373 --- /dev/null +++ b/resources/boost/skills/javaabu-generators/SKILL.md @@ -0,0 +1,96 @@ +--- +name: javaabu-generators +description: Scaffold Laravel admin resources from database tables with javaabu/generators (php artisan generate:all, generate:model, generate:form, generate:filters, generate:bulk-actions, generate:controller, generate:views, generate:test, generate:auth). Use when creating CRUD or admin pages for a table, a model with its factory, policy, permissions, stencil form, filters, bulk actions, controller, routes, Blade or Inertia React pages and tests; when choosing which fields appear on index, form and show pages; nesting a resource under a parent; adding mediapicker media collections; making a model translatable; or creating a javaabu/auth user type (customers, vendors, admins) with its guard and routes. +license: MIT +metadata: + author: Javaabu +--- + +# Admin resources with javaabu/generators + +The docs ship with the package: `vendor/javaabu/generators/llms.txt` lists every page in `vendor/javaabu/generators/docs/`. + +| Task | Read | +| --- | --- | +| Generate a resource, choose fields, nest, media, user types | This file | +| Every command, option, config key and what each part writes | [references/reference.md](references/reference.md) | + +## The workflow + +1. Write the migration. Everything comes from it: types, nullability, defaults, `unique()`, foreign keys, `softDeletes()`, enum columns. Run `php artisan migrate`. +2. Generate the tables it refers to first (`categories` before `products`). +3. Look at the code, then write it: + +```bash +php artisan generate:all products # prints the code +php artisan generate:all products --create # writes the files +php artisan permissions:sync # writes the new permissions +php artisan test --filter=ProductsControllerTest # the generated tests pass as generated +``` + +`generate:all` writes: + +| File | Built on | +| --- | --- | +| `app/Models/Product.php` | Casts, relations (belongsTo, hasMany, belongsToMany through pivots), `SoftDeletes` | +| `database/factories/ProductFactory.php` | | +| `app/Policies/ProductPolicy.php` | `extends Javaabu\Permissions\Policies\ResourcePolicy` | +| `AppServiceProvider::boot()` | `Permissions::resource('products', label: 'Products')` | +| `app/Filters/ProductFilters.php` | javaabu/filters | +| `app/BulkActions/ProductBulkActions.php` | javaabu/bulk-actions | +| `app/Stencil/ProductForm.php` | javaabu/stencil, which also validates (no FormRequest) | +| `app/Http/Controllers/Admin/ProductsController.php` | `Gate::authorize()` with the policy; `ProductForm::make()->model(...)->save()` | +| `routes/admin.php` | `Route::resource()`, restore and force delete routes, `Route::bulkActions()` | +| `resources/views/admin/products/*` or `resources/js/pages/admin/products/*.tsx` | Blade (javaabu/forms v2) or Inertia React (@javaabu/forms) | +| `tests/Feature/Admin/ProductsControllerTest.php` | Feature tests of every page | +| `lang/dv.json` | Dhivehi for the generic phrases | + +## Choosing what is generated + +```bash +php artisan generate:all products --create --index=name,price,status --form=name,price,status,category_id,tags +php artisan generate:all products --create --interactive # checklists for each page +php artisan generate:all products --create --views=inertia # Inertia React pages +php artisan generate:all reviews --create --parent=products # /admin/products/{product}/reviews +php artisan generate:all posts --create --media=featured_image:image,gallery:image:multiple +php artisan generate:all products --create --except=test,views # skip parts +php artisan generate:form products # one part +``` + +- Many-to-many relations are chosen by their relation name (`tags`). +- `--except` takes `model`, `factory`, `policy`, `permissions`, `filters`, `bulk-actions`, `form`, `controller`, `routes`, `views`, `test` and `translations`. + +## User types + +```bash +php artisan generate:auth customers --migration --create # the migration, with UserSchema::columns() +php artisan migrate +php artisan generate:auth customers --create # --type=customer --guard=customer by default +``` + +This writes: + +- the model, which extends `Javaabu\Auth\Models\User` +- the guard, provider and password broker in `config/auth.php` +- `UserTypes::register('customer', [...])` in `AppServiceProvider::boot()` +- `Route::authRoutes('customer', ['prefix' => 'customer'])` in `routes/web.php` +- the admin pages of customers, with a password and status field, and `Route::userAccountActions()` + +javaabu/auth ships the login and account controllers and pages: don't write them. Protect the type's own routes with `auth.type:customer`. + +## Enums and translations + +- **Database enums** (`$table->enum()`) become selects with the options. +- **PHP enums:** name the class in a column comment (`->comment('enum:App\Enums\OrderStatuses')`, MySQL/PostgreSQL) or in `config('generators.enums')` (`'orders.status' => OrderStatuses::class`, every driver). The enum must be backed. +- **Translatable models:** tables with `translations` (json) and `lang` columns get `JsonTranslatable` models and `->translatable()` fields when javaabu/translatable is installed (`--translatable=title` to choose the columns). + +## Common mistakes + +- Generating before migrating, or from a table that changed since: generate again with `--force` only for files nobody edited. +- Generating `products` before `categories`: the factory and tests use `Category::factory()`. +- Writing a FormRequest for a generated resource: the stencil form's fields are the rules. Change the rules on the fields. +- Checking permission names in the controller: authorize with the policy (`Gate::authorize('update', $product)`). +- Expecting comments to name enum classes on SQLite: use `generators.enums`. +- Editing `routes/admin.php` below or around the `// generators: routes` marker: new resources go above it, inside the admin group. +- Forgetting `php artisan permissions:sync` after generating. +- For Inertia pages: the app needs `@javaabu/forms`, `@javaabu/i18n`, javaabu/manifest's Vite plugin, and `resolvePage(name, import.meta.glob("./pages/**/*.tsx"), { base: "./pages/" })`. diff --git a/resources/boost/skills/javaabu-generators/references/reference.md b/resources/boost/skills/javaabu-generators/references/reference.md new file mode 100644 index 0000000..c6ac8b1 --- /dev/null +++ b/resources/boost/skills/javaabu-generators/references/reference.md @@ -0,0 +1,72 @@ +# javaabu/generators reference + +## Commands + +Every command takes `{table}` and the options below. Without `--create` it prints the code. + +| Command | Writes | +| --- | --- | +| `generate:all` | Every part below except the export | +| `generate:model` | `app/Models/{Model}.php` | +| `generate:factory` | `database/factories/{Model}Factory.php` | +| `generate:policy` | `app/Policies/{Model}Policy.php` (`ResourcePolicy`, `$resource = '{plural}'`, `$guard` when `generators.admin.guard` is set) | +| `generate:permissions` | `Permissions::resource('{plural}', label: '...')` in `AppServiceProvider::boot()` | +| `generate:filters` | `app/Filters/{Model}Filters.php` | +| `generate:bulk-actions` | `app/BulkActions/{Model}BulkActions.php` | +| `generate:form` | `app/Stencil/{Model}Form.php` | +| `generate:controller` | `app/Http/Controllers/Admin/{Models}Controller.php` | +| `generate:routes` | Routes in `routes/admin.php`, above `// generators: routes` | +| `generate:views` | Blade views, or (`--views=inertia`) pages, `types.ts` and `app/Http/Resources/Admin/{Model}Resource.php` | +| `generate:test` | `tests/Feature/Admin/{Models}ControllerTest.php` | +| `generate:export` | `app/Exports/{Models}Export.php` (javaabu/exports `ModelExport`) | +| `generate:auth` | A javaabu/auth user type: model, `config/auth.php`, `UserTypes::register()`, `Route::authRoutes()`, `Route::userAccountActions()`, and the admin pages. `--type`, `--guard`, `--migration` | +| `generate:rollback` | `git reset`, `git checkout .`, `git clean -fd` (every uncommitted change) | + +## Options + +| Option | What | +| --- | --- | +| `--create`, `-c` | Write files | +| `--force`, `-f` | Overwrite existing files | +| `--columns=a,b` | Only these columns, in every part | +| `--index=a,b`, `--form=a,b`, `--show=a,b` | Fields per page; many-to-many relations by name | +| `--interactive`, `-i` | Prompts for the fields, parent, media and views | +| `--views=blade\|inertia` | Default `generators.views` | +| `--parent=table\|foreign_key\|relation` | Nested resource: `Route::resource('parents.children')->scoped()` | +| `--media=name[:type][:multiple],...` | Types: image, video, audio, document, archive | +| `--translatable[=a,b]` | Detect or choose translatable columns (needs `translations` and `lang` columns and javaabu/translatable) | +| `--except=part,...` | `generate:all`/`generate:auth`: model, factory, policy, permissions, filters, bulk-actions, form, controller, routes, views, test, translations (and config for `generate:auth`) | + +## Column types + +- string/char/uuid: `TextInput` with `maxLength`; `email()`, `tel()`, `url()` by name. They're searched. +- text: `Textarea`, searched. +- integers: `TextInput::integer()` with min/max; cast `integer`; number range filter on the index. +- decimal: `TextInput::numeric()->decimals()`; cast `decimal:n`. +- boolean (MySQL `tinyint(1)`): `Checkbox`; cast `boolean`; `TernaryFilter`. +- enum (type or check constraint), or an enum class from a comment or `generators.enums`: `Select::options()`; `SelectFilter`. +- date, datetime/timestamp, time: `DatePicker`, `DateTimePicker`, `TimePicker`; date casts; `DateRangeFilter`. +- year: `TextInput::integer()` 1901 to 2155. +- json: `Select::multiple()->tags()`, cast `array`. +- foreign key: `Select::relationship('category', 'name')->searchable()`, a `belongsTo`, a `SelectFilter`, eager loaded. +- pivot table: `belongsToMany` on both models, `Select::make('tags')->multiple()->relationship('tags', 'name')`. +- SQLite keeps no lengths, precision, unsigned or comments. + +## Config (`config/generators.php`) + +`views`, `connection`, `admin.namespace`, `admin.route_prefix`, `admin.route_name`, `admin.view_prefix`, `admin.page_prefix`, `admin.routes_file`, `admin.middleware`, `admin.guard`, `admin.user_model`, `blade.layout`, `blade.section`, `namespaces.*`, `tinyint1_to_bool`, `skip_columns`, `enums`, `name_fields`, `index_columns`, `ignored_tables`, `translatable` (`'auto'`|`false`), `auth.skip_columns`. + +## The generated tests need + +- `Tests\TestCase` +- the admin model with a factory and `HasRoles` +- `permissions:sync` (in their `setUp()`) +- the related tables' factories +- for Inertia: `inertia.testing.page_paths` with `resources/js/pages` + +## Extending + +- `Javaabu\Generators\Commands\GenerateCommand`: implement `generators(ResourceDefinition $resource): array`. +- `Javaabu\Generators\Generators\Generator`: implement `files(): array` of `GeneratedFile`. +- Bind a subclass of `Javaabu\Generators\Resource\ResourceResolver` and override `makeField()` to map columns to your own `Javaabu\Generators\Fields\Field` types. +- Publish stubs: `php artisan vendor:publish --tag=generators-stubs` (`stubs/vendor/generators`). diff --git a/src/Commands.php b/src/Commands.php new file mode 100644 index 0000000..844038d --- /dev/null +++ b/src/Commands.php @@ -0,0 +1,46 @@ + + */ + public static function all(): array + { + return [ + GenerateAllCommand::class, + GenerateModelCommand::class, + GenerateFactoryCommand::class, + GeneratePolicyCommand::class, + GeneratePermissionsCommand::class, + GenerateFiltersCommand::class, + GenerateBulkActionsCommand::class, + GenerateFormCommand::class, + GenerateControllerCommand::class, + GenerateRoutesCommand::class, + GenerateViewsCommand::class, + GenerateTestCommand::class, + GenerateExportCommand::class, + GenerateAuthCommand::class, + GenerateRollbackCommand::class, + ]; + } +} diff --git a/src/Commands/Auth/BaseAuthGenerateCommand.php b/src/Commands/Auth/BaseAuthGenerateCommand.php deleted file mode 100644 index d2fea72..0000000 --- a/src/Commands/Auth/BaseAuthGenerateCommand.php +++ /dev/null @@ -1,55 +0,0 @@ -option('create'); - $force = (bool) $this->option('force'); - $path = (string) $this->option('path'); - $auth_name = (string) $this->option('auth_name'); - - if ($create) { - $this->createFiles($table, $columns, $auth_name, $force, $path); - } else { - $this->createOutput($table, $columns, $auth_name); - } - } - - protected function getGeneratorClass(): string - { - return property_exists($this, 'generator_class') ? $this->generator_class : ''; - } - - protected function getGenerator(string $table, array $columns, string $auth_name): ?BaseAuthGenerator - { - if ($class = $this->getGeneratorClass()) { - return new $class($table, $columns, $auth_name); - } - - return null; - } - - protected abstract function createOutput(string $table, array $columns, string $auth_name): void; - - protected abstract function createFiles(string $table, array $columns, string $auth_name, bool $force = false, string $path = ''): void; - - -} diff --git a/src/Commands/Auth/Controllers/GenerateAuthConfirmPasswordControllerCommand.php b/src/Commands/Auth/Controllers/GenerateAuthConfirmPasswordControllerCommand.php deleted file mode 100644 index f3fe481..0000000 --- a/src/Commands/Auth/Controllers/GenerateAuthConfirmPasswordControllerCommand.php +++ /dev/null @@ -1,14 +0,0 @@ -getGenerator($table, $columns, $auth_name); - $output = $generator->render(); - $controller = $generator->getControllerName(); - - if (app()->runningInConsole()) { - $this->info("Schema-based auth $controller for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your controller class:'); - } - - $this->line($output); - } - - protected function createFiles(string $table, array $columns, string $auth_name, bool $force = false, string $path = ''): void - { - /** @var BaseAuthControllerGenerator $generator */ - $generator = $this->getGenerator($table, $columns, $auth_name); - - $path = $this->getPath(app_path($generator->getControllerPath()), $path); - - $file_name = $generator->getControllerName() . '.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $output = $generator->render(); - - if ($this->putContent($file_path, $output, $force)) { - $this->info("$file_name created!"); - } - } -} diff --git a/src/Commands/Auth/Controllers/GenerateAuthForgotPasswordControllerCommand.php b/src/Commands/Auth/Controllers/GenerateAuthForgotPasswordControllerCommand.php deleted file mode 100644 index 6c9bf4d..0000000 --- a/src/Commands/Auth/Controllers/GenerateAuthForgotPasswordControllerCommand.php +++ /dev/null @@ -1,14 +0,0 @@ -getGenerator($table, $columns, $auth_name); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based auth config for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your auth config file:'); - } - - $this->line($output); - } - - protected function createFiles(string $table, array $columns, string $auth_name, bool $force = false, string $path = ''): void - { - $path = $this->getPath(config_path('/'), $path); - - $file_name = 'auth.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns, $auth_name); - - $replacements = [ - [ - 'search' => "'guards' => [\n", - 'keep_search' => true, - 'content' => $generator->renderGuards() . "\n", - ], - [ - 'search' => "'providers' => [\n", - 'keep_search' => true, - 'content' => $generator->renderProviders() . "\n", - ], - [ - 'search' => "'passwords' => [\n", - 'keep_search' => true, - 'content' => $generator->renderPasswords() . "\n", - ], - [ - 'search' => "'passport_guards' => [\n", - 'keep_search' => true, - 'content' => $generator->renderPassportGuards(), - ], - ]; - - if ($generator->shouldRenderDefaultGuard()) { - $replacements[] = [ - 'search' => "'guard' => env('AUTH_GUARD', 'web_admin'),\n", - 'keep_search' => false, - 'content' => $generator->renderDefaultGuard(), - ]; - - $replacements[] = [ - 'search' => "'guard' => env('AUTH_GUARD', 'web'),\n", - 'keep_search' => false, - 'content' => $generator->renderDefaultGuard(), - ]; - - $replacements[] = [ - 'search' => "'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),\n", - 'keep_search' => false, - 'content' => $generator->renderDefaultPasswords(), - ]; - } - - if ($this->appendContent($file_path, $replacements)) { - $this->info("$table auth config created!"); - } - } -} diff --git a/src/Commands/Auth/GenerateAuthFactoryCommand.php b/src/Commands/Auth/GenerateAuthFactoryCommand.php deleted file mode 100644 index 13249ff..0000000 --- a/src/Commands/Auth/GenerateAuthFactoryCommand.php +++ /dev/null @@ -1,44 +0,0 @@ -getGenerator($table, $columns, $auth_name); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based factory for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your factory class:'); - } - - $this->line($output); - } - - protected function createFiles(string $table, array $columns, string $auth_name, bool $force = false, string $path = ''): void - { - $path = $this->getPath(database_path('factories'), $path); - - $file_name = StringCaser::singularStudly($table) . 'Factory.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns, $auth_name); - $output = $generator->render(); - - if ($this->putContent($file_path, $output, $force)) { - $this->info("$file_name created!"); - } - } -} diff --git a/src/Commands/Auth/GenerateAuthModelCommand.php b/src/Commands/Auth/GenerateAuthModelCommand.php deleted file mode 100644 index 8d62ab0..0000000 --- a/src/Commands/Auth/GenerateAuthModelCommand.php +++ /dev/null @@ -1,84 +0,0 @@ -getGenerator($table, $columns, $auth_name); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based auth model class for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your model class:'); - } - - $this->line($output); - } - - protected function createFiles(string $table, array $columns, string $auth_name, bool $force = false, string $path = ''): void - { - // create the model - $this->createModel($table, $columns, $auth_name, $force, $path); - - $this->addSubjectTypeAndMorphMap($table); - } - - protected function createModel(string $table, array $columns, string $auth_name, bool $force = false, string $path = ''): void - { - // create the model - $path = $this->getPath(app_path('Models'), $path); - - $file_name = StringCaser::singularStudly($table) . '.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns, $auth_name); - $output = $generator->render(); - - if ($this->putContent($file_path, $output, $force)) { - $this->info("$file_name created!"); - } - } - - protected function addSubjectTypeAndMorphMap(string $table): void - { - $file_path = app_path('Providers/AppServiceProvider.php'); - - $morph_name = StringCaser::singularSnake($table); - $class_name = StringCaser::singularStudly($table); - - $replacements = [ - [ - 'search' => "Relation::enforceMorphMap([\n", - 'keep_search' => true, - 'content' => $this->getRenderer()->addIndentation("'$morph_name' => \\App\\Models\\$class_name::class,\n", 3), - ], - [ - 'search' => "SubjectTypes::register([\n", - 'keep_search' => true, - 'content' => $this->getRenderer()->addIndentation("\\App\\Models\\$class_name::class,\n", 3), - ], - [ - 'search' => "CauserTypes::register([\n", - 'keep_search' => true, - 'force' => true, - 'content' => $this->getRenderer()->addIndentation("\\App\\Models\\$class_name::class,\n", 3), - ], - ]; - - if ($this->appendContent($file_path, $replacements)) { - $this->info("$table causer type, subject type and morph map added!"); - } - } -} diff --git a/src/Commands/Auth/GenerateAuthPasswordResetsCommand.php b/src/Commands/Auth/GenerateAuthPasswordResetsCommand.php deleted file mode 100644 index b2586a3..0000000 --- a/src/Commands/Auth/GenerateAuthPasswordResetsCommand.php +++ /dev/null @@ -1,44 +0,0 @@ -getGenerator($table, $columns, $auth_name); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based password resets migration for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your migration file:'); - } - - $this->line($output); - } - - protected function createFiles(string $table, array $columns, string $auth_name, bool $force = false, string $path = ''): void - { - $path = $this->getPath(database_path('migrations'), $path); - - $generator = $this->getGenerator($table, $columns, $auth_name); - - $file_name = $generator->getPasswordResetsMigrationName() . '.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $output = $generator->render(); - - if ($this->putContent($file_path, $output, $force)) { - $this->info("$file_name created!"); - } - } -} diff --git a/src/Commands/Auth/GenerateAuthPermissionsCommand.php b/src/Commands/Auth/GenerateAuthPermissionsCommand.php deleted file mode 100644 index a903d5b..0000000 --- a/src/Commands/Auth/GenerateAuthPermissionsCommand.php +++ /dev/null @@ -1,53 +0,0 @@ -getGenerator($table, $columns, $auth_name); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based auth permissions for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your permissions seeder file:'); - } - - $this->line($output); - } - - protected function createFiles(string $table, array $columns, string $auth_name, bool $force = false, string $path = ''): void - { - $path = $this->getPath(database_path('seeders'), $path); - - $stub = 'generators::seeders/PermissionsSeeder.stub'; - $file_name = 'PermissionsSeeder.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns, $auth_name); - $output = $generator->render(); - - $replacements = [ - [ - 'search' => 'protected $data = ['."\n", - 'keep_search' => true, - 'content' => $output . "\n", - ], - ]; - - if ($this->appendContent($file_path, $replacements, $stub)) { - $this->info("$table auth permissions created!"); - } - } -} diff --git a/src/Commands/Auth/GenerateAuthPolicyCommand.php b/src/Commands/Auth/GenerateAuthPolicyCommand.php deleted file mode 100644 index 7e91f10..0000000 --- a/src/Commands/Auth/GenerateAuthPolicyCommand.php +++ /dev/null @@ -1,45 +0,0 @@ -getGenerator($table, $columns, $auth_name); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based auth model policy for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your policy class:'); - } - - $this->line($output); - } - - protected function createFiles(string $table, array $columns, string $auth_name, bool $force = false, string $path = ''): void - { - $path = $this->getPath(app_path('Policies'), $path); - - $file_name = StringCaser::singularStudly($table) . 'Policy.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns, $auth_name); - $output = $generator->render(); - - if ($this->putContent($file_path, $output, $force)) { - $this->info("$file_name created!"); - } - } -} diff --git a/src/Commands/Auth/GenerateAuthRequestCommand.php b/src/Commands/Auth/GenerateAuthRequestCommand.php deleted file mode 100644 index 87a427d..0000000 --- a/src/Commands/Auth/GenerateAuthRequestCommand.php +++ /dev/null @@ -1,45 +0,0 @@ -getGenerator($table, $columns, $auth_name); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based auth form request for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your request class:'); - } - - $this->line($output); - } - - protected function createFiles(string $table, array $columns, string $auth_name, bool $force = false, string $path = ''): void - { - $path = $this->getPath(app_path('Http/Requests'), $path); - - $file_name = StringCaser::pluralStudly($table) . 'Request.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns, $auth_name); - $output = $generator->render(); - - if ($this->putContent($file_path, $output, $force)) { - $this->info("$file_name created!"); - } - } -} diff --git a/src/Commands/BaseGenerateCommand.php b/src/Commands/BaseGenerateCommand.php deleted file mode 100644 index c9a6102..0000000 --- a/src/Commands/BaseGenerateCommand.php +++ /dev/null @@ -1,39 +0,0 @@ -option('create'); - $force = (bool) $this->option('force'); - $path = (string) $this->option('path'); - - if ($create) { - $this->createFiles($table, $columns, $force, $path); - } else { - $this->createOutput($table, $columns); - } - } - - protected function getGeneratorClass(): string - { - return property_exists($this, 'generator_class') ? $this->generator_class : ''; - } - - protected function getGenerator(string $table, array $columns): ?BaseGenerator - { - if ($class = $this->getGeneratorClass()) { - return new $class($table, $columns); - } - - return null; - } - - protected abstract function createOutput(string $table, array $columns): void; - - protected abstract function createFiles(string $table, array $columns, bool $force = false, string $path = ''): void; -} diff --git a/src/Commands/GenerateAllCommand.php b/src/Commands/GenerateAllCommand.php index 3a935c9..495c6f5 100644 --- a/src/Commands/GenerateAllCommand.php +++ b/src/Commands/GenerateAllCommand.php @@ -2,22 +2,77 @@ namespace Javaabu\Generators\Commands; -class GenerateAllCommand extends MultipleGeneratorCommand +use Javaabu\Generators\Generators\BladeViewsGenerator; +use Javaabu\Generators\Generators\BulkActionsGenerator; +use Javaabu\Generators\Generators\ControllerGenerator; +use Javaabu\Generators\Generators\FactoryGenerator; +use Javaabu\Generators\Generators\FiltersGenerator; +use Javaabu\Generators\Generators\FormGenerator; +use Javaabu\Generators\Generators\InertiaPagesGenerator; +use Javaabu\Generators\Generators\ModelGenerator; +use Javaabu\Generators\Generators\PermissionsGenerator; +use Javaabu\Generators\Generators\PolicyGenerator; +use Javaabu\Generators\Generators\ResourceGenerator; +use Javaabu\Generators\Generators\RoutesGenerator; +use Javaabu\Generators\Generators\TestGenerator; +use Javaabu\Generators\Generators\TranslationsGenerator; +use Javaabu\Generators\Resource\ResourceDefinition; +use Symfony\Component\Console\Input\InputOption; + +class GenerateAllCommand extends GenerateCommand { protected $name = 'generate:all'; - protected $description = 'Generate all model code based on your database table schema'; - - protected array $commands = [ - 'factory', - 'permissions', - 'model', - 'policy', - 'request', - 'export', - 'controller', - 'routes', - 'test', - 'views' + protected $description = 'Generate a complete admin resource from a table: model, factory, policy, permissions, filters, bulk actions, form, controller, routes, views and tests'; + + /** + * The parts, by the name --except takes. + * + * @var array> + */ + public const array PARTS = [ + 'model' => [ModelGenerator::class], + 'factory' => [FactoryGenerator::class], + 'policy' => [PolicyGenerator::class], + 'permissions' => [PermissionsGenerator::class], + 'filters' => [FiltersGenerator::class], + 'bulk-actions' => [BulkActionsGenerator::class], + 'form' => [FormGenerator::class], + 'controller' => [ControllerGenerator::class], + 'routes' => [RoutesGenerator::class], + 'views' => [], + 'test' => [TestGenerator::class], + 'translations' => [TranslationsGenerator::class], ]; + + protected function getOptions(): array + { + return [ + ...parent::getOptions(), + ['except', null, InputOption::VALUE_REQUIRED, 'Parts to skip (comma separated): ' . implode(', ', array_keys(self::PARTS))], + ]; + } + + protected function generators(ResourceDefinition $resource): array + { + $except = array_filter(array_map('trim', explode(',', (string) $this->option('except')))); + + $generators = []; + + foreach (self::PARTS as $part => $classes) { + if (in_array($part, $except, true)) { + continue; + } + + if ($part === 'views') { + $classes = $resource->isInertia() + ? [ResourceGenerator::class, InertiaPagesGenerator::class] + : [BladeViewsGenerator::class]; + } + + array_push($generators, ...$classes); + } + + return $generators; + } } diff --git a/src/Commands/GenerateApiCommand.php b/src/Commands/GenerateApiCommand.php deleted file mode 100644 index 96ded66..0000000 --- a/src/Commands/GenerateApiCommand.php +++ /dev/null @@ -1,16 +0,0 @@ -getGenerator($table, $columns); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based model api controller for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your api controller file:'); - } - - $this->line($output); - } - - protected function createFiles(string $table, array $columns, bool $force = false, string $path = ''): void - { - $path = $this->getPath(app_path('Http/Controllers/Api'), $path); - - $file_name = StringCaser::pluralStudly($table) . 'Controller.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - if ($this->putContent($file_path, $output, $force)) { - $this->info("$file_name created!"); - } - - $this->generateApiRoutes($generator); - } - - protected function generateApiRoutes(ApiControllerGenerator $generator): void - { - $path = base_path('routes'); - - $table = $generator->getTable(); - $file_name = 'api.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $output = $generator->renderRoutes(); - - $replacements = [ - [ - 'search' => '// Api Public Routes - DONOT REMOVE' . "\n", - 'keep_search' => true, - 'content' => "\n" . $output, - ], - [ - 'search' => 'use Illuminate\\Support\\Facades\\Route;'."\n", - 'keep_search' => true, - 'content' => 'use App\\Http\\Controllers\\Api\\' . StringCaser::pluralStudly($generator->getTable()) . 'Controller;' . "\n", - ], - ]; - - if ($this->appendContent($file_path, $replacements)) { - $this->info("$table api routes created!"); - } - } -} diff --git a/src/Commands/GenerateApiTestCommand.php b/src/Commands/GenerateApiTestCommand.php deleted file mode 100644 index 57ac81a..0000000 --- a/src/Commands/GenerateApiTestCommand.php +++ /dev/null @@ -1,44 +0,0 @@ -getGenerator($table, $columns); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based model api controller test for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your api controller test file:'); - } - - $this->line($output); - } - - protected function createFiles(string $table, array $columns, bool $force = false, string $path = ''): void - { - $path = $this->getPath(base_path('tests/Feature/Controllers/Api'), $path); - - $file_name = StringCaser::pluralStudly($table) . 'ControllerTest.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - if ($this->putContent($file_path, $output, $force)) { - $this->info("$file_name created!"); - } - } -} diff --git a/src/Commands/GenerateAuthCommand.php b/src/Commands/GenerateAuthCommand.php new file mode 100644 index 0000000..f2384aa --- /dev/null +++ b/src/Commands/GenerateAuthCommand.php @@ -0,0 +1,109 @@ +argument('table'); + + $inspector = app(SchemaInspector::class); + $inspector->flush(); + + if (! $inspector->hasTable($table) && $this->option('migration')) { + return $this->writeMigration($table); + } + + return parent::handle(); + } + + protected function writeMigration(string $table): int + { + $file = (new AuthMigrationGenerator($table))->file(); + + if (! $this->option('create')) { + $this->line("// {$file->relativePath()}"); + $this->line($file->contents); + + return self::SUCCESS; + } + + $filesystem = app(Filesystem::class); + $filesystem->ensureDirectoryExists(dirname($file->path)); + $filesystem->put($file->path, $file->contents); + + $this->components->twoColumnDetail($file->relativePath(), __('generators::generators.created')); + $this->components->info(__('generators::generators.auth_migrate', ['table' => $table])); + + return self::SUCCESS; + } + + protected function resourceOptions(): ResourceOptions + { + $options = parent::resourceOptions(); + + $type = $this->option('type') ?: Str::singular(Str::snake((string) $this->argument('table'))); + + return new ResourceOptions( + columns: $options->columns, + index: $options->index, + form: $options->form, + show: $options->show, + parent: $options->parent, + media: $options->media, + views: $options->views, + translatable: false, + user_type: Str::snake($type), + user_guard: $this->option('guard') ?: null, + ); + } + + protected function generators(ResourceDefinition $resource): array + { + $except = array_filter(array_map('trim', explode(',', (string) $this->option('except')))); + + $auth = []; + + if (! in_array('config', $except, true)) { + $auth[] = AuthConfigGenerator::class; + $auth[] = UserTypeGenerator::class; + } + + if (! in_array('routes', $except, true)) { + $auth[] = AuthRoutesGenerator::class; + } + + return [...$auth, ...parent::generators($resource)]; + } +} diff --git a/src/Commands/GenerateBulkActionsCommand.php b/src/Commands/GenerateBulkActionsCommand.php new file mode 100644 index 0000000..f9309bd --- /dev/null +++ b/src/Commands/GenerateBulkActionsCommand.php @@ -0,0 +1,18 @@ +> + */ + abstract protected function generators(ResourceDefinition $resource): array; - /** @return array */ - protected function getArguments() + protected function getArguments(): array { return [ - ['table', InputArgument::REQUIRED, 'The table of which you want to generate from'] + ['table', InputArgument::REQUIRED, 'The table to generate from'], ]; } - /** @return array */ - protected function getOptions() + protected function getOptions(): array { return [ - ['columns', null, InputOption::VALUE_REQUIRED, 'Only generate for specific columns of the table', ''], - ['create', 'c', InputOption::VALUE_NONE, 'Instead of outputting the generated code, create actual files'], - ['force', 'f', InputOption::VALUE_NONE, 'If "create" was given, then the files gets created even if they already exists'], - ['path', 'p', InputOption::VALUE_REQUIRED, 'Specify the path to create the files'], + ['columns', null, InputOption::VALUE_REQUIRED, 'Only use these columns (comma separated)'], + ['index', null, InputOption::VALUE_REQUIRED, 'The columns of the index table (comma separated)'], + ['form', null, InputOption::VALUE_REQUIRED, 'The fields of the form (comma separated, many-to-many relations by name)'], + ['show', null, InputOption::VALUE_REQUIRED, 'The fields of the show page (comma separated)'], + ['parent', null, InputOption::VALUE_REQUIRED, 'Nest the resource under the table of this foreign key (e.g. category_id or categories)'], + ['media', null, InputOption::VALUE_REQUIRED, 'Media collections: name[:type][:multiple], e.g. featured_image:image,gallery:image:multiple'], + ['views', null, InputOption::VALUE_REQUIRED, 'blade or inertia'], + ['translatable', null, InputOption::VALUE_OPTIONAL, 'Make the model translatable: the columns, or none to detect them', false], + ['interactive', 'i', InputOption::VALUE_NONE, 'Choose the fields, parent, media and views with prompts'], + ['create', 'c', InputOption::VALUE_NONE, 'Write the files instead of printing the code'], + ['force', 'f', InputOption::VALUE_NONE, 'Overwrite files that exist'], ]; } - /** - * @throws BindingResolutionException - * @throws MultipleTablesSuppliedException - * @throws TableDoesNotExistException - * @throws ColumnDoesNotExistException - */ public function handle(): int { - // Arguments $table = (string) $this->argument('table'); - // Options - $columns = (array) array_filter(explode(',', $this->option('columns'))); + if (str_contains($table, ',')) { + throw new MultipleTablesSuppliedException(__('generators::generators.one_table', ['tables' => $table])); + } + + $inspector = app(SchemaInspector::class); + $inspector->flush(); + + if (! $inspector->hasTable($table)) { + throw new TableDoesNotExistException(__('generators::generators.no_table', ['table' => $table])); + } + + $relations = app(RelationResolver::class); + + if ($relations->isPivotTable($pivot = $inspector->table($table))) { + $related = [$pivot->foreign_keys[0]->foreign_table, $pivot->foreign_keys[1]->foreign_table]; + sort($related); + + throw new InvalidArgumentException(__('generators::generators.pivot_table', [ + 'table' => $table, + 'first' => $related[0], + 'second' => $related[1], + ])); + } - $this->checkTableAndColumns($table, $columns); + $resource = $this->resolveResource($table); + + $files = $this->files($resource); + + if (! $this->option('create')) { + $this->printFiles($files); + + return self::SUCCESS; + } - $this->runGenerator($table, $columns); + $this->writeFiles($files); - return Command::SUCCESS; + return self::SUCCESS; } + public function resolveResource(string $table): ResourceDefinition + { + $options = $this->resourceOptions(); + + $resolver = app(ResourceResolver::class); + $resource = $resolver->resolve($table, $options); - protected abstract function runGenerator(string $table, array $columns): void; + if (! $this->option('interactive') || ! $this->input->isInteractive()) { + return $resource; + } + + return $resolver->resolve($table, $this->promptForOptions($resource, $options)); + } + + protected function resourceOptions(): ResourceOptions + { + $translatable = $this->option('translatable'); + + return ResourceOptions::fromArray([ + 'columns' => $this->option('columns'), + 'index' => $this->option('index'), + 'form' => $this->option('form'), + 'show' => $this->option('show'), + 'parent' => $this->option('parent'), + 'media' => $this->option('media') ?? '', + 'views' => $this->option('views'), + 'translatable' => $translatable === false ? null : ($translatable ?? true), + 'user_type' => $this->hasOption('type') ? $this->option('type') : null, + ]); + } /** - * @throws MultipleTablesSuppliedException - * @throws ColumnDoesNotExistException - * @throws TableDoesNotExistException + * Ask for the choices that weren't given as options. */ - protected function checkTableAndColumns(string $table, array $columns = []): void + protected function promptForOptions(ResourceDefinition $resource, ResourceOptions $options): ResourceOptions { - if (count($tables = array_filter(explode(',', $table))) > 1) { - $msg = 'The command can only handle one table at a time - you gave: '.implode(', ', $tables); + $choices = []; - throw new MultipleTablesSuppliedException($msg); + foreach ($resource->fields as $column => $field) { + $choices[$column] = $field->getLabel() . " ({$column})"; } - if (! Schema::hasTable($table)) { - throw new TableDoesNotExistException("Table '$table' not found!"); + foreach ($resource->belongs_to_many as $relation) { + $choices[$relation->name] = ucfirst($relation->name) . ' (' . __('generators::generators.many_to_many') . ')'; } - if (empty($columns)) { - return; + $parent = $options->parent; + + if ($parent === null) { + $foreign_keys = array_filter($resource->fields, fn ($field) => $field instanceof ForeignKeyField); + + if ($foreign_keys) { + $parent = select( + label: __('generators::generators.prompt_parent'), + options: ['' => __('generators::generators.not_nested'), ...array_map(fn (ForeignKeyField $field) => $field->getRelatedTable(), $foreign_keys)], + default: '', + ) ?: null; + } } - $missingColumns = []; - foreach ($columns as $column) { - if (! Schema::hasColumn($table, $column)) { - $missingColumns[] = $column; + $index = $options->index ?? multiselect( + label: __('generators::generators.prompt_index'), + options: $choices, + default: $resource->index_columns, + scroll: 12, + ); + + $form = $options->form ?? multiselect( + label: __('generators::generators.prompt_form'), + options: $choices, + default: $resource->form_columns, + scroll: 12, + ); + + $show = $options->show ?? multiselect( + label: __('generators::generators.prompt_show'), + options: $choices, + default: $resource->show_columns, + scroll: 12, + ); + + $media = $options->media; + + if (! $media) { + $media = MediaCollection::parse(text( + label: __('generators::generators.prompt_media'), + placeholder: 'featured_image:image, gallery:image:multiple', + hint: __('generators::generators.prompt_media_hint'), + )); + } + + $views = $options->views ?? select( + label: __('generators::generators.prompt_views'), + options: ['blade' => 'Blade', 'inertia' => 'Inertia React'], + default: $resource->views, + ); + + return new ResourceOptions( + columns: $options->columns, + index: array_values($index), + form: array_values($form), + show: array_values($show), + parent: $parent, + media: $media, + views: $views, + translatable: $options->translatable, + user_type: $options->user_type, + ); + } + + /** + * @return array + */ + public function files(ResourceDefinition $resource): array + { + $files = []; + + foreach ($this->generators($resource) as $class) { + /** @var Generator $generator */ + $generator = new $class($resource); + + foreach ($generator->files() as $file) { + $files[] = $file; + } + } + + return $files; + } + + /** + * @param array $files + */ + protected function printFiles(array $files): void + { + foreach ($files as $file) { + $this->line("// {$file->relativePath()}" . ($file->isMerge() ? ' (' . __('generators::generators.added_to') . ')' : '')); + foreach (explode("\n", rtrim($file->contents)) as $line) { + $this->line($line); } + + $this->newLine(); } + } + + /** + * @param array $files + */ + protected function writeFiles(array $files): void + { + $filesystem = app(Filesystem::class); + $force = (bool) $this->option('force'); + + foreach ($files as $file) { + $path = $file->path; + $exists = $filesystem->exists($path); + + if ($file->isMerge()) { + $current = $exists ? $filesystem->get($path) : ''; + $contents = $file->mergeInto($current); + + if ($contents === $current) { + $this->components->twoColumnDetail($file->relativePath(), __('generators::generators.unchanged')); + + continue; + } + + $filesystem->ensureDirectoryExists(dirname($path)); + $filesystem->put($path, $contents); + + $this->components->twoColumnDetail($file->relativePath(), $exists ? __('generators::generators.updated') : __('generators::generators.created')); + + continue; + } + + if ($exists && ! $force) { + $this->components->twoColumnDetail($file->relativePath(), '' . __('generators::generators.exists') . ''); + + continue; + } - if (! empty($missingColumns)) { - $msg = "The following columns do not exists on the table '$table': ".implode(', ', $missingColumns); + $filesystem->ensureDirectoryExists(dirname($path)); + $filesystem->put($path, $file->contents); - throw new ColumnDoesNotExistException($msg); + $this->components->twoColumnDetail($file->relativePath(), $exists ? __('generators::generators.overwritten') : __('generators::generators.created')); } } } diff --git a/src/Commands/GenerateControllerCommand.php b/src/Commands/GenerateControllerCommand.php index b052aac..5975aa2 100644 --- a/src/Commands/GenerateControllerCommand.php +++ b/src/Commands/GenerateControllerCommand.php @@ -3,42 +3,16 @@ namespace Javaabu\Generators\Commands; use Javaabu\Generators\Generators\ControllerGenerator; -use Javaabu\GeneratorHelpers\StringCaser; +use Javaabu\Generators\Resource\ResourceDefinition; -class GenerateControllerCommand extends BaseGenerateCommand +class GenerateControllerCommand extends GenerateCommand { - protected $name = 'generate:controller'; - protected $description = 'Generate model controller based on your database table schema'; - - protected string $generator_class = ControllerGenerator::class; - - protected function createOutput(string $table, array $columns): void - { - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based model controller for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your controller class:'); - } + protected $description = 'Generate the admin controller of a table'; - $this->line($output); - } - - protected function createFiles(string $table, array $columns, bool $force = false, string $path = ''): void + protected function generators(ResourceDefinition $resource): array { - $path = $this->getPath(app_path('Http/Controllers/Admin'), $path); - - $file_name = StringCaser::pluralStudly($table) . 'Controller.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - if ($this->putContent($file_path, $output, $force)) { - $this->info("$file_name created!"); - } + return [ControllerGenerator::class]; } } diff --git a/src/Commands/GenerateExportCommand.php b/src/Commands/GenerateExportCommand.php index 8762e30..5bc9c76 100644 --- a/src/Commands/GenerateExportCommand.php +++ b/src/Commands/GenerateExportCommand.php @@ -3,42 +3,16 @@ namespace Javaabu\Generators\Commands; use Javaabu\Generators\Generators\ExportGenerator; -use Javaabu\GeneratorHelpers\StringCaser; +use Javaabu\Generators\Resource\ResourceDefinition; -class GenerateExportCommand extends BaseGenerateCommand +class GenerateExportCommand extends GenerateCommand { - protected $name = 'generate:export'; - protected $description = 'Generate model export based on your database table schema'; - - protected string $generator_class = ExportGenerator::class; - - protected function createOutput(string $table, array $columns): void - { - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based model export for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your export class:'); - } + protected $description = 'Generate the export of a table (javaabu/exports)'; - $this->line($output); - } - - protected function createFiles(string $table, array $columns, bool $force = false, string $path = ''): void + protected function generators(ResourceDefinition $resource): array { - $path = $this->getPath(app_path('Exports'), $path); - - $file_name = StringCaser::pluralStudly($table) . 'Export.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - if ($this->putContent($file_path, $output, $force)) { - $this->info("$file_name created!"); - } + return [ExportGenerator::class]; } } diff --git a/src/Commands/GenerateFactoryCommand.php b/src/Commands/GenerateFactoryCommand.php index ace3fc1..95b4d5c 100644 --- a/src/Commands/GenerateFactoryCommand.php +++ b/src/Commands/GenerateFactoryCommand.php @@ -3,42 +3,16 @@ namespace Javaabu\Generators\Commands; use Javaabu\Generators\Generators\FactoryGenerator; -use Javaabu\GeneratorHelpers\StringCaser; +use Javaabu\Generators\Resource\ResourceDefinition; -class GenerateFactoryCommand extends BaseGenerateCommand +class GenerateFactoryCommand extends GenerateCommand { - protected $name = 'generate:factory'; - protected $description = 'Generate model factory based on your database table schema'; - - protected string $generator_class = FactoryGenerator::class; - - protected function createOutput(string $table, array $columns): void - { - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based factory for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your factory class:'); - } + protected $description = 'Generate the model factory of a table'; - $this->line($output); - } - - protected function createFiles(string $table, array $columns, bool $force = false, string $path = ''): void + protected function generators(ResourceDefinition $resource): array { - $path = $this->getPath(database_path('factories'), $path); - - $file_name = StringCaser::singularStudly($table) . 'Factory.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - if ($this->putContent($file_path, $output, $force)) { - $this->info("$file_name created!"); - } + return [FactoryGenerator::class]; } } diff --git a/src/Commands/GenerateFiltersCommand.php b/src/Commands/GenerateFiltersCommand.php new file mode 100644 index 0000000..3971ac5 --- /dev/null +++ b/src/Commands/GenerateFiltersCommand.php @@ -0,0 +1,18 @@ +getGenerator($table, $columns); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based model class for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your model class:'); - } - - $this->line($output); - } - - protected function createFiles(string $table, array $columns, bool $force = false, string $path = ''): void - { - // create the model - $this->createModel($table, $columns, $force, $path); - - $this->addSubjectTypeAndMorphMap($table); - } - - protected function createModel(string $table, array $columns, bool $force = false, string $path = ''): void - { - // create the model - $path = $this->getPath(app_path('Models'), $path); - - $file_name = StringCaser::singularStudly($table) . '.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); + protected $description = 'Generate the model of a table'; - if ($this->putContent($file_path, $output, $force)) { - $this->info("$file_name created!"); - } - } - - protected function addSubjectTypeAndMorphMap(string $table): void + protected function generators(ResourceDefinition $resource): array { - $file_path = app_path('Providers/AppServiceProvider.php'); - - $morph_name = StringCaser::singularSnake($table); - $class_name = StringCaser::singularStudly($table); - - $replacements = [ - [ - 'search' => "Relation::enforceMorphMap([\n", - 'keep_search' => true, - 'content' => $this->getRenderer()->addIndentation("'$morph_name' => \\App\\Models\\$class_name::class,\n", 3), - ], - [ - 'search' => "SubjectTypes::register([\n", - 'keep_search' => true, - 'content' => $this->getRenderer()->addIndentation("\\App\\Models\\$class_name::class,\n", 3), - ], - ]; - - if ($this->appendContent($file_path, $replacements)) { - $this->info("$table subject type and morph map added!"); - } + return [ModelGenerator::class]; } } diff --git a/src/Commands/GeneratePermissionsCommand.php b/src/Commands/GeneratePermissionsCommand.php index c44ec1b..5c7982a 100644 --- a/src/Commands/GeneratePermissionsCommand.php +++ b/src/Commands/GeneratePermissionsCommand.php @@ -3,51 +3,16 @@ namespace Javaabu\Generators\Commands; use Javaabu\Generators\Generators\PermissionsGenerator; -use Javaabu\GeneratorHelpers\StringCaser; +use Javaabu\Generators\Resource\ResourceDefinition; -class GeneratePermissionsCommand extends BaseGenerateCommand +class GeneratePermissionsCommand extends GenerateCommand { - protected $name = 'generate:permissions'; - protected $description = 'Generate model permissions based on your database table schema'; - - protected string $generator_class = PermissionsGenerator::class; + protected $description = 'Register the permissions of a table (javaabu/permissions)'; - protected function createOutput(string $table, array $columns): void + protected function generators(ResourceDefinition $resource): array { - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based model permissions for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your permissions class:'); - } - - $this->line($output); - } - - protected function createFiles(string $table, array $columns, bool $force = false, string $path = ''): void - { - $path = $this->getPath(database_path('seeders'), $path); - - $stub = 'generators::seeders/PermissionsSeeder.stub'; - $file_name = 'PermissionsSeeder.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - $replacements = [ - [ - 'search' => 'protected $data = ['."\n", - 'keep_search' => true, - 'content' => $output . "\n", - ], - ]; - - if ($this->appendContent($file_path, $replacements, $stub)) { - $this->info("$table permissions created!"); - } + return [PermissionsGenerator::class]; } } diff --git a/src/Commands/GeneratePolicyCommand.php b/src/Commands/GeneratePolicyCommand.php index 5a2f47e..ad3b8a7 100644 --- a/src/Commands/GeneratePolicyCommand.php +++ b/src/Commands/GeneratePolicyCommand.php @@ -3,42 +3,16 @@ namespace Javaabu\Generators\Commands; use Javaabu\Generators\Generators\PolicyGenerator; -use Javaabu\GeneratorHelpers\StringCaser; +use Javaabu\Generators\Resource\ResourceDefinition; -class GeneratePolicyCommand extends BaseGenerateCommand +class GeneratePolicyCommand extends GenerateCommand { - protected $name = 'generate:policy'; - protected $description = 'Generate model policy based on your database table schema'; - - protected string $generator_class = PolicyGenerator::class; - - protected function createOutput(string $table, array $columns): void - { - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based model policy for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your policy class:'); - } + protected $description = 'Generate the policy of a table (javaabu/permissions)'; - $this->line($output); - } - - protected function createFiles(string $table, array $columns, bool $force = false, string $path = ''): void + protected function generators(ResourceDefinition $resource): array { - $path = $this->getPath(app_path('Policies'), $path); - - $file_name = StringCaser::singularStudly($table) . 'Policy.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - if ($this->putContent($file_path, $output, $force)) { - $this->info("$file_name created!"); - } + return [PolicyGenerator::class]; } } diff --git a/src/Commands/GenerateRequestCommand.php b/src/Commands/GenerateRequestCommand.php deleted file mode 100644 index 78c42db..0000000 --- a/src/Commands/GenerateRequestCommand.php +++ /dev/null @@ -1,44 +0,0 @@ -getGenerator($table, $columns); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based form request for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your request class:'); - } - - $this->line($output); - } - - protected function createFiles(string $table, array $columns, bool $force = false, string $path = ''): void - { - $path = $this->getPath(app_path('Http/Requests'), $path); - - $file_name = StringCaser::pluralStudly($table) . 'Request.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - if ($this->putContent($file_path, $output, $force)) { - $this->info("$file_name created!"); - } - } -} diff --git a/src/Commands/GenerateRollbackCommand.php b/src/Commands/GenerateRollbackCommand.php index 1b9e669..032fff2 100644 --- a/src/Commands/GenerateRollbackCommand.php +++ b/src/Commands/GenerateRollbackCommand.php @@ -4,13 +4,9 @@ use Illuminate\Console\Command; use Illuminate\Console\ConfirmableTrait; -use Illuminate\Contracts\Container\BindingResolutionException; -use Javaabu\Generators\Exceptions\ColumnDoesNotExistException; -use Javaabu\Generators\Exceptions\MultipleTablesSuppliedException; -use Javaabu\Generators\Exceptions\TableDoesNotExistException; use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Process\Process; use Symfony\Component\Process\Exception\ProcessFailedException; +use Symfony\Component\Process\Process; class GenerateRollbackCommand extends Command { @@ -18,54 +14,40 @@ class GenerateRollbackCommand extends Command protected $name = 'generate:rollback'; - protected $description = 'Rolls back changes after running a generate command.'; - + protected $description = 'Roll back the changes of a generate command (discards every uncommitted change in git)'; - /** @return array */ - protected function getOptions() + protected function getOptions(): array { return [ - ['force', 'f', InputOption::VALUE_NONE, 'Runs the command without any user prompts'], + ['force', 'f', InputOption::VALUE_NONE, 'Run the command without asking'], ]; } - /** - * @throws BindingResolutionException - * @throws MultipleTablesSuppliedException - * @throws TableDoesNotExistException - * @throws ColumnDoesNotExistException - */ public function handle(): int { - if (! $this->confirmToProceed('If you run this command, you will lose all uncommitted changes!', true)) { - return 0; + if (! $this->confirmToProceed(__('generators::generators.rollback_warning'), true)) { + return self::FAILURE; } $commands = [ ['git', 'reset'], ['git', 'checkout', '.'], - ['git', 'clean', '-fd'] + ['git', 'clean', '-fd'], ]; - $this->runCommands($commands); - - $this->info('Generator changes rolled back'); - - return Command::SUCCESS; - } - - protected function runCommands(array $commands): void - { - foreach ($commands as $command_args) { - $command = new Process($command_args); - $command->run(); + foreach ($commands as $arguments) { + $process = new Process($arguments, base_path()); + $process->run(); - // executes after the command finishes - if (! $command->isSuccessful()) { - throw new ProcessFailedException($command); + if (! $process->isSuccessful()) { + throw new ProcessFailedException($process); } - $this->info($command->getOutput()); + $this->line($process->getOutput()); } + + $this->components->info(__('generators::generators.rolled_back')); + + return self::SUCCESS; } } diff --git a/src/Commands/GenerateRoutesCommand.php b/src/Commands/GenerateRoutesCommand.php index 826b7e5..1d7baf1 100644 --- a/src/Commands/GenerateRoutesCommand.php +++ b/src/Commands/GenerateRoutesCommand.php @@ -3,55 +3,16 @@ namespace Javaabu\Generators\Commands; use Javaabu\Generators\Generators\RoutesGenerator; -use Javaabu\GeneratorHelpers\StringCaser; +use Javaabu\Generators\Resource\ResourceDefinition; -class GenerateRoutesCommand extends BaseGenerateCommand +class GenerateRoutesCommand extends GenerateCommand { - protected $name = 'generate:routes'; - protected $description = 'Generate model routes based on your database table schema'; - - protected string $generator_class = RoutesGenerator::class; + protected $description = 'Add the admin routes of a table'; - protected function createOutput(string $table, array $columns): void + protected function generators(ResourceDefinition $resource): array { - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based model routes for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your routes file:'); - } - - $this->line($output); - } - - protected function createFiles(string $table, array $columns, bool $force = false, string $path = ''): void - { - $path = $this->getPath(base_path('routes'), $path); - - $file_name = 'admin.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - $replacements = [ - [ - 'search' => '// Generator Routes - DONOT REMOVE'."\n\n", - 'keep_search' => true, - 'content' => $output . "\n", - ], - [ - 'search' => 'use Illuminate\\Support\\Facades\\Route;'."\n", - 'keep_search' => true, - 'content' => 'use App\\Http\\Controllers\\Admin\\' . StringCaser::pluralStudly($generator->getTable()) . 'Controller;' . "\n", - ], - ]; - - if ($this->appendContent($file_path, $replacements)) { - $this->info("$table routes created!"); - } + return [RoutesGenerator::class]; } } diff --git a/src/Commands/GenerateTestCommand.php b/src/Commands/GenerateTestCommand.php index 2057cb7..05580c7 100644 --- a/src/Commands/GenerateTestCommand.php +++ b/src/Commands/GenerateTestCommand.php @@ -3,42 +3,16 @@ namespace Javaabu\Generators\Commands; use Javaabu\Generators\Generators\TestGenerator; -use Javaabu\GeneratorHelpers\StringCaser; +use Javaabu\Generators\Resource\ResourceDefinition; -class GenerateTestCommand extends BaseGenerateCommand +class GenerateTestCommand extends GenerateCommand { - protected $name = 'generate:test'; - protected $description = 'Generate model controller test based on your database table schema'; - - protected string $generator_class = TestGenerator::class; - - protected function createOutput(string $table, array $columns): void - { - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based model controller test for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your test class:'); - } + protected $description = 'Generate the controller tests of a table'; - $this->line($output); - } - - protected function createFiles(string $table, array $columns, bool $force = false, string $path = ''): void + protected function generators(ResourceDefinition $resource): array { - $path = $this->getPath(base_path('tests/Feature/Controllers/Admin'), $path); - - $file_name = StringCaser::pluralStudly($table) . 'ControllerTest.php'; - $file_path = $this->getFullFilePath($path, $file_name); - - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - if ($this->putContent($file_path, $output, $force)) { - $this->info("$file_name created!"); - } + return [TestGenerator::class]; } } diff --git a/src/Commands/GenerateViewsCommand.php b/src/Commands/GenerateViewsCommand.php index d6ee654..7973136 100644 --- a/src/Commands/GenerateViewsCommand.php +++ b/src/Commands/GenerateViewsCommand.php @@ -2,74 +2,23 @@ namespace Javaabu\Generators\Commands; -use Javaabu\Generators\Generators\ViewsGenerator; -use Javaabu\GeneratorHelpers\StringCaser; +use Javaabu\Generators\Generators\BladeViewsGenerator; +use Javaabu\Generators\Generators\InertiaPagesGenerator; +use Javaabu\Generators\Generators\ResourceGenerator; +use Javaabu\Generators\Resource\ResourceDefinition; -class GenerateViewsCommand extends BaseGenerateCommand +class GenerateViewsCommand extends GenerateCommand { - protected $name = 'generate:views'; - protected $description = 'Generate model views based on your database table schema'; - - protected string $generator_class = ViewsGenerator::class; - - protected function createOutput(string $table, array $columns): void - { - $generator = $this->getGenerator($table, $columns); - $output = $generator->render(); - - if (app()->runningInConsole()) { - $this->info("Schema-based model views for table \"$table\" have been generated!"); - $this->info('Copy & paste these to your view files:'); - } - - $this->line($output); - } + protected $description = 'Generate the Blade views or Inertia React pages of a table'; - protected function createFiles(string $table, array $columns, bool $force = false, string $path = ''): void + protected function generators(ResourceDefinition $resource): array { - /** @var ViewsGenerator $generator */ - $generator = $this->getGenerator($table, $columns); - - $views = $generator->viewsToRender(); - - foreach ($views as $method => $file_name) { - $this->createView($file_name, $method, $generator, $force, $path); + if ($resource->isInertia()) { + return [ResourceGenerator::class, InertiaPagesGenerator::class]; } - $this->addSidebarLinks($generator); - } - - protected function createView(string $file_name, string $method, ViewsGenerator $generator, bool $force = false, string $path = ''): void - { - // create the view - $folder_name = StringCaser::pluralKebab($generator->getTable()); - $path = $this->getPath(resource_path('views/admin/' . $folder_name), $path); - - $file_path = $this->getFullFilePath($path, $file_name); - - $output = $generator->{$method}(); - - if ($this->putContent($file_path, $output, $force)) { - $this->info("$file_name created!"); - } - } - - protected function addSidebarLinks(ViewsGenerator $generator): void - { - $file_path = app_path('Menus/AdminSidebar.php'); - - $replacements = [ - [ - 'search' => "->route('admin.home'),\n", - 'keep_search' => true, - 'content' => "\n" . $generator->renderSidebarLinks(), - ], - ]; - - if ($this->appendContent($file_path, $replacements)) { - $this->info("{$generator->getTable()} sidebar links added!"); - } + return [BladeViewsGenerator::class]; } } diff --git a/src/Commands/MultipleGeneratorCommand.php b/src/Commands/MultipleGeneratorCommand.php deleted file mode 100644 index 708b159..0000000 --- a/src/Commands/MultipleGeneratorCommand.php +++ /dev/null @@ -1,56 +0,0 @@ -commands : []; - - // Options - $skipped_commands = (array) array_filter(explode(',', $this->option('except'))); - - return array_diff($defined_commands, $skipped_commands); - } - - protected function createOutput(string $table, array $columns): void - { - $this->callCommands($table, $columns, false); - } - - protected function createFiles(string $table, array $columns, bool $force = false, string $path = ''): void - { - $this->callCommands($table, $columns, true, $force, $path); - } - - protected function callCommands(string $table, array $columns, bool $create = false, bool $force = false, string $path = ''): void - { - $commands = $this->getCommands(); - - foreach ($commands as $command) { - Artisan::call("generate:$command", [ - 'table' => $table, - '--columns' => implode(',', $columns), - '--create' => $create, - '--force' => $force, - '--path' => $path, - ]); - - $this->info(Artisan::output()); - } - } -} diff --git a/src/Contracts/SchemaResolverInterface.php b/src/Contracts/SchemaResolverInterface.php deleted file mode 100644 index c61caec..0000000 --- a/src/Contracts/SchemaResolverInterface.php +++ /dev/null @@ -1,18 +0,0 @@ - 1, - ]; - } - - public function getEntryComponentName(): string - { - return 'boolean-entry'; - } - - public function renderAssignment(string $prefix = '$request->input(', string $suffix = ')'): string - { - if (Str::startsWith($suffix, ')')) { - $suffix = ', false)'; - } elseif (Str::startsWith($suffix, ']')) { - $suffix = '] ?? false'; - } - - return parent::renderAssignment($prefix, $suffix); - } -} diff --git a/src/FieldTypes/DateField.php b/src/FieldTypes/DateField.php deleted file mode 100644 index da369e2..0000000 --- a/src/FieldTypes/DateField.php +++ /dev/null @@ -1,32 +0,0 @@ -format(\'Y-m-d H:i\')'; - } - - public function generateCast(): ?string - { - return 'datetime'; - } - - public function generateCorrectValue(): string - { - return "'2024-02-12 14:54:00'"; - } - - public function generateDifferentCorrectValue(): string - { - return "'2023-01-11 13:30:00'"; - } - - public function getFormComponentName(): string - { - return 'datetime'; - } -} diff --git a/src/FieldTypes/DateTypeField.php b/src/FieldTypes/DateTypeField.php deleted file mode 100644 index b6439ca..0000000 --- a/src/FieldTypes/DateTypeField.php +++ /dev/null @@ -1,21 +0,0 @@ -total_digits = $total_digits; - $this->places = $places; - $this->unsigned = $unsigned; - } - - public function isUnsigned(): bool - { - return $this->unsigned; - } - - public function getTotalDigits(): int - { - return $this->total_digits; - } - - public function getPlaces(): int - { - return $this->places; - } - - public function generateFactoryStatement(): string - { - return "randomFloat({$this->getPlaces()}, {$this->getMin()}, {$this->getMax()})"; - } - - public function generateValidationRules(): array - { - return ['decimal:0,'.$this->getPlaces(), 'min:'.$this->getMin(), 'max:'.$this->getMax()]; - } - - public function generateCast(): ?string - { - return 'decimal:'.$this->getPlaces(); - } - - public function isSortable(): bool - { - return true; - } - - public function generateWrongValue(): string - { - return "'foo'"; - } - - public function generateCorrectValue(): string - { - return '10.50'; - } - - public function generateDifferentCorrectValue(): string - { - return '5.24'; - } - - public function getFormComponentName(): string - { - return 'number'; - } - - public function getStep(): string - { - if ($places = $this->getPlaces()) { - return '0.' . Str::repeat('0', $places - 1) . '1'; - } - - return '1'; - } - - public function getFormComponentAttributes(): array - { - return [ - 'min' => $this->getMin(), - 'max' => $this->getMax(), - 'step' => $this->getStep(), - ]; - } -} diff --git a/src/FieldTypes/EnumField.php b/src/FieldTypes/EnumField.php deleted file mode 100644 index 18cef97..0000000 --- a/src/FieldTypes/EnumField.php +++ /dev/null @@ -1,171 +0,0 @@ -options = $options;; - $this->enum_class = $enum_class; - } - - public function hasEnumClass(): bool - { - return ! empty($this->getEnumClass()); - } - - public function getEnumClassBasename(): string - { - return $this->hasEnumClass() ? class_basename($this->getEnumClass()) : ''; - } - - public function getEnumClass(): ?string - { - return $this->enum_class; - } - - public function getFullyQualifiedEnumClass(): ?string - { - $enum_class = $this->getEnumClass(); - - return '\\' . ltrim($enum_class, '\\'); - } - - public function getOptions(): array - { - return $this->options; - } - - public function getEnumCases(): array - { - $enum = $this->getEnumClass(); - - return $enum ? $enum::cases() : []; - } - - public function getOptionsString(): string - { - return '[' . collect($this->getOptions()) - ->transform(function ($value) { - return "'" . $value. "'"; - })->implode(', ') . ']'; - } - - public function generateFactoryStatement(): string - { - if ($this->hasEnumClass()) { - $array = "array_column({$this->getFullyQualifiedEnumClass()}::cases(), 'value')"; - } else { - $array = $this->getOptionsString(); - } - - return "randomElement($array)"; - } - - public function generateValidationRules(): array - { - if ($this->hasEnumClass()) { - return ["Rule::enum({$this->getEnumClassBasename()}::class)"]; - } - - return ['in:'.implode(',', $this->getOptions())]; - } - - public function generateFactoryInput(): string - { - $input = $this->getName(); - - if ($this->hasEnumClass()) { - $input .= ($this->isNullable() ? '?' : '') . '->value'; - } - - return $input; - } - - public function shouldQuoteCast(): bool - { - return false; - } - - public function generateCast(): ?string - { - return $this->hasEnumClass() ? $this->getEnumClassBasename() . '::class' : null; - } - - public function generateFactoryDbValue(): string - { - $value = parent::generateFactoryDbValue(); - - if ($this->hasEnumClass()) { - $value .= ($this->isNullable() ? '?' : '') . '->value'; - } - - return $value; - } - - public function generateWrongValue(): string - { - return '[]'; - } - - public function generateCorrectValue(): string - { - return $this->hasEnumClass() ? $this->getEnumClassBasename() . '::' . $this->getEnumCases()[0]->name . '->value' : "'" . $this->getOptions()[0] . "'"; - } - - public function generateDifferentCorrectValue(): string - { - if ($this->hasEnumClass()) { - $cases = $this->getEnumCases(); - $secondCase = $cases[1] ?? $cases[0]; // Fallback to the first case if the second doesn't exist - return $this->getEnumClassBasename() . '::' . $secondCase->name . '->value'; - } - - return "'" . ($this->getOptions()[1] ?? $this->getOptions()[0]) . "'"; - } - - public function getFormComponentAttributes(): array - { - return [ - ':options' => $this->hasEnumClass() ? $this->generateEnumClassSelectOptions() : $this->getOptionsString(), - ]; - } - - public function generateEnumClassSelectOptions(): string - { - if (method_exists($this->getEnumClass(), 'getLabels')) { - return $this->getFullyQualifiedEnumClass() . '::getLabels()'; - } - - return "array_column({$this->getFullyQualifiedEnumClass()}::cases(), 'name', 'value')"; - } - - public function getFormComponentName(): string - { - return 'select2'; - } -} diff --git a/src/FieldTypes/Field.php b/src/FieldTypes/Field.php deleted file mode 100644 index 72715fb..0000000 --- a/src/FieldTypes/Field.php +++ /dev/null @@ -1,286 +0,0 @@ -name = $name; - $this->nullable = $nullable; - $this->min = $min; - $this->max = $max; - $this->default = $default; - $this->unique = $unique; - } - - public function isRequired(): bool - { - return (! $this->isNullable()) && (! $this->hasDefault()); - } - - public function hasDefault(): bool - { - return ! is_null($this->getDefault()); - } - - public function isNullable(): bool - { - return $this->nullable; - } - - public function isUnique(): bool - { - return $this->unique; - } - - public function getDefault() - { - return $this->default; - } - - public function getName() - { - return $this->name; - } - - public function getMin() - { - return $this->min; - } - - public function getMax() - { - return $this->max; - } - - public function getInputName(): string - { - return $this->getName(); - } - - public function getFormInputName(): string - { - return $this->getInputName(); - } - - public function getLabel(): string - { - return StringCaser::title($this->getInputName()); - } - - public function isFillable(): bool - { - return true; - } - - public function isSearchable(): bool - { - return false; - } - - public function isSortable(): bool - { - return false; - } - - public function getFormComponentAttributes(): array - { - return []; - } - - public function getEntryComponentAttributes(): array - { - return []; - } - - public function getTableCellComponentAttributes(): array - { - return []; - } - - public function shouldRenderInputInline(): bool - { - return true; - } - - public function shouldRenderInputPlaceholder(): bool - { - return true; - } - - public function shouldRenderEntryInline(): bool - { - return true; - } - - protected function renderComponentAttributes(array $attributes): string - { - $attribs = []; - - foreach ($attributes as $attribute => $value) { - if (is_bool($value)) { - $attribs[] = $value ? $attribute : ':' . $attribute . '="false"'; - } else { - $attribs[] = $attribute . '="' . $value . '"'; - } - } - - return implode(' ', $attribs); - } - - public function renderFormComponentAttributes(): string - { - $attributes = $this->getFormComponentAttributes(); - - if ($this->isRequired()) { - $attributes['required'] = true; - } - - $attributes['inline'] = $this->shouldRenderInputInline(); - - if ($this->shouldRenderInputPlaceholder()) { - $attributes['show-placeholder'] = true; - } - - return $this->renderComponentAttributes($attributes); - } - - public function renderFormComponent(): string - { - $attributes = $this->renderFormComponentAttributes(); - - return 'getFormComponentName() . ' name="' . $this->getFormInputName() . '" ' . ($attributes ? $attributes . ' ' : '') . '/>'; - } - - public function renderEntryComponentAttributes(): string - { - $attributes = $this->getEntryComponentAttributes(); - - $attributes['inline'] = $this->shouldRenderEntryInline(); - - return $this->renderComponentAttributes($attributes); - } - - public function getEntryAttributeName(): string - { - return $this->getInputName(); - } - - public function renderEntryComponent(): string - { - $attributes = $this->renderEntryComponentAttributes(); - - return 'getEntryComponentName() . ' name="' . $this->getEntryAttributeName() . '" ' . ($attributes ? $attributes . ' ' : '') . '/>'; - } - - public function getEntryComponentName(): string - { - return 'text-entry'; - } - - public function renderTableCellComponentAttributes(): string - { - $attributes = $this->getTableCellComponentAttributes(); - - return $this->renderComponentAttributes($attributes); - } - - public function getTableCellAttributeName(): string - { - return $this->getEntryAttributeName(); - } - - public function renderTableCellComponent(): string - { - $attributes = $this->renderTableCellComponentAttributes(); - - return 'getTableCellComponentName() . ' name="' . $this->getTableCellAttributeName() . '" ' . ($attributes ? $attributes . ' ' : '') . '/>'; - } - - public function renderTableTitleComponent(): string - { - $sortable = $this->isSortable() ? ' sortable="' . $this->getName() . '"' : ''; - - return ''; - } - - public function getTableCellComponentName(): string - { - return 'table.cell'; - } - - public function generateFactoryDbValue(): string - { - return $this->getName(); - } - - public function formatFactoryDbValue(string $value): string - { - return $value; - } - - public function generateWrongDbValue(): string - { - return $this->generateWrongValue(); - } - - public function generateCorrectDbValue(): string - { - return $this->generateCorrectValue(); - } - - public function generateDifferentCorrectDbValue(): string - { - return $this->generateDifferentCorrectValue(); - } - - public function shouldQuoteCast(): bool - { - return true; - } - - public function generateFactoryInput(): string - { - return $this->getName(); - } - - public function renderAssignment(string $prefix = '$request->input(', string $suffix = ')'): string - { - return $this->getName() . ' = ' . $prefix . "'" . $this->getInputName() . "'" . $suffix; - } - - public abstract function generateDifferentCorrectValue(): string; - - public abstract function getFormComponentName(): string; - - public abstract function generateFactoryStatement(): string; - - public abstract function generateValidationRules(): array; - - public abstract function generateCast(): ?string; - - public abstract function generateWrongValue(): string; - - public abstract function generateCorrectValue(): string; -} diff --git a/src/FieldTypes/ForeignKeyField.php b/src/FieldTypes/ForeignKeyField.php deleted file mode 100644 index 3416336..0000000 --- a/src/FieldTypes/ForeignKeyField.php +++ /dev/null @@ -1,163 +0,0 @@ -related_table = $related_table; - $this->related_key_name = $related_key_name; - } - - public function renderAssignment(string $prefix = '$request->input(', string $suffix = ')'): string - { - return $this->getRelationName() . '()->associate(' . $prefix . "'" . $this->getInputName() . "'" . $suffix . ')'; - } - - public function getRelatedTable(): string - { - return $this->related_table; - } - - public function getRelatedKeyName(): string - { - return $this->related_key_name; - } - - public function getRelatedModelClass(): string - { - return StringCaser::singularStudly($this->getRelatedTable()); - } - - public function getRelatedModelMorph(): string - { - return StringCaser::singularSnake($this->getRelatedTable()); - } - - public function getInputName(): string - { - $name = $this->getName(); - - if (Str::endsWith($name, '_id') && ($new_name = Str::beforeLast($name, '_id'))) { - return $new_name; - } - - return $name; - } - - public function getRelationName(): string - { - return StringCaser::camel($this->getInputName()); - } - - public function generateFactoryStatement(): string - { - $model_class = $this->getRelatedModelClass(); - $key_name = $this->getRelatedKeyName(); - $unique = ''; - - if ($this->isUnique()) { - $unique = ', unique: true'; - } - - return 'passThrough(random_id_or_generate(\\App\\Models\\'.$model_class.'::class, \'' . $key_name. '\', generate: true'.$unique.'))'; - } - - public function generateValidationRules(): array - { - return ['exists:' . $this->getRelatedTable() . ',' . $this->getRelatedKeyName()]; - } - - public function isFillable(): bool - { - return false; - } - - public function generateCast(): ?string - { - return null; - } - - public function generateRelationStatement(): string - { - $statement = 'belongsTo(' . $this->getRelatedModelClass() . '::class'; - - $related_key = $this->getRelatedKeyName(); - - if ((! Str::endsWith($this->getName(), '_id')) || $related_key != 'id') { - $statement .= ', \'' . $this->getName() . '\''; - - if ($related_key != 'id') { - $statement .= ', \'' . $related_key . '\''; - } - } - - $statement .= ')'; - - return $statement; - } - - public function generateWrongValue(): string - { - return '-1'; - } - - public function generateCorrectValue(): string - { - return '$old_' . $this->getInputName() . '->' . $this->getRelatedKeyName(); - } - - public function generateDifferentCorrectValue(): string - { - return '$new_' . $this->getInputName() . '->' . $this->getRelatedKeyName(); - } - - public function generateTestFactoryStatement(string $prefix): string - { - return '$' . $prefix . $this->getInputName() . ' = $this->getFactory(' . $this->getRelatedModelClass() . '::class)->create();'; - } - - public function getEntryAttributeName(): string - { - return $this->getRelationName(); - } - - public function getFormComponentName(): string - { - return 'select2'; - } - - public function getFormComponentAttributes(): array - { - $attributes = [ - ':options' => '\\App\\Models\\' . $this->getRelatedModelClass() . '::query()', - 'relation' => true, - ]; - - return $attributes; - } -} diff --git a/src/FieldTypes/IntegerField.php b/src/FieldTypes/IntegerField.php deleted file mode 100644 index fabdf9e..0000000 --- a/src/FieldTypes/IntegerField.php +++ /dev/null @@ -1,91 +0,0 @@ -unsigned = $unsigned; - } - - public function isUnsigned(): bool - { - return $this->unsigned; - } - - public function generateFactoryStatement(): string - { - $min = $this->getMin(); - $max = $this->getMax(); - - return "numberBetween($min, $max)"; - } - - public function generateValidationRules(): array - { - return ['integer', 'min:' . $this->getMin(), 'max:' . $this->getMax()]; - } - - public function generateCast(): ?string - { - return 'integer'; - } - - public function isSortable(): bool - { - return true; - } - - public function generateWrongValue(): string - { - return "'foo'"; - } - - public function generateCorrectValue(): string - { - return '1'; - } - - public function generateDifferentCorrectValue(): string - { - return '2'; - } - - public function getFormComponentName(): string - { - return 'number'; - } - - public function getFormComponentAttributes(): array - { - return [ - 'min' => $this->getMin(), - 'max' => $this->getMax(), - 'step' => 1, - ]; - } -} diff --git a/src/FieldTypes/JsonField.php b/src/FieldTypes/JsonField.php deleted file mode 100644 index 789f3c9..0000000 --- a/src/FieldTypes/JsonField.php +++ /dev/null @@ -1,75 +0,0 @@ -getInputName() . '[]'; - } - - public function generateFactoryStatement(): string - { - return 'words()'; - } - - public function formatFactoryDbValue(string $value): string - { - return "is_null($value) ? null : json_encode($value)"; - } - - public function generateValidationRules(): array - { - return ['array']; - } - - public function generateCast(): ?string - { - return 'array'; - } - - public function generateWrongDbValue(): string - { - return 'json_encode(' . $this->generateWrongValue() . ')'; - } - - public function generateCorrectDbValue(): string - { - return 'json_encode(' . $this->generateCorrectValue() . ')'; - } - - public function generateDifferentCorrectDbValue(): string - { - return 'json_encode(' . $this->generateDifferentCorrectValue() . ')'; - } - - public function generateWrongValue(): string - { - return "'foo'"; - } - - public function generateCorrectValue(): string - { - return "['apple']"; - } - - public function generateDifferentCorrectValue(): string - { - return "['orange']"; - } - - public function getFormComponentName(): string - { - return 'select2'; - } - - public function getFormComponentAttributes(): array - { - return [ - ':options' => "['apple', 'orange']", - 'multiple' => true, - ]; - } -} diff --git a/src/FieldTypes/StringField.php b/src/FieldTypes/StringField.php deleted file mode 100644 index 419a418..0000000 --- a/src/FieldTypes/StringField.php +++ /dev/null @@ -1,64 +0,0 @@ -getMax(); - - if ($max < 5) { - return "regexify('[a-z]{{$max}}')"; - } - - return "text(fake()->numberBetween(5, $max))"; - } - - public function generateValidationRules(): array - { - return ['string', 'max:'.$this->getMax()]; - } - - public function isSearchable(): bool - { - return true; - } - - public function generateCast(): ?string - { - return 'string'; - } - - public function isSortable(): bool - { - return true; - } - - public function generateWrongValue(): string - { - return '[]'; - } - - public function generateCorrectValue(): string - { - return "'foo'"; - } - - public function generateDifferentCorrectValue(): string - { - return "'bar'"; - } - - public function getFormComponentName(): string - { - return 'text'; - } - - public function getFormComponentAttributes(): array - { - return [ - 'maxlength' => $this->getMax(), - ]; - } -} diff --git a/src/FieldTypes/TextField.php b/src/FieldTypes/TextField.php deleted file mode 100644 index 53c1772..0000000 --- a/src/FieldTypes/TextField.php +++ /dev/null @@ -1,55 +0,0 @@ - true, - ]; - } - - public function getTableCellComponentAttributes(): array - { - return [ - 'multiline' => true, - ]; - } -} diff --git a/src/FieldTypes/TimeField.php b/src/FieldTypes/TimeField.php deleted file mode 100644 index 3ef8383..0000000 --- a/src/FieldTypes/TimeField.php +++ /dev/null @@ -1,32 +0,0 @@ -getMax(); - - return "year($max)"; - } - - public function generateValidationRules(): array - { - return ['integer', 'min:' . $this->getMin(), 'max:' . $this->getMax()]; - } - - public function generateCast(): ?string - { - return 'integer'; - } - - public function isSortable(): bool - { - return true; - } - - public function generateWrongValue(): string - { - return '-1'; - } - - public function generateCorrectValue(): string - { - return '2024'; - } - - public function generateDifferentCorrectValue(): string - { - return '2023'; - } - - public function getFormComponentAttributes(): array - { - return [ - 'min' => $this->getMin(), - 'max' => $this->getMax(), - 'step' => 1, - ]; - } - - public function getFormComponentName(): string - { - return 'number'; - } -} diff --git a/src/Fields/BooleanField.php b/src/Fields/BooleanField.php new file mode 100644 index 0000000..601e9ba --- /dev/null +++ b/src/Fields/BooleanField.php @@ -0,0 +1,80 @@ +boolean()'; + } + + protected function fakerCall(): string + { + return 'boolean()'; + } + + protected function stencilClass(): string + { + return 'Javaabu\\Stencil\\Fields\\Checkbox'; + } + + public function getFilter(Imports $imports): ?string + { + $class = $imports->add('Javaabu\\Filters\\TernaryFilter'); + + return "{$class}::make('{$this->getName()}')->label({$this->getLabelCall()})"; + } + + public function getBladeEntry(string $variable): string + { + return ''; + } + + public function getReactEntry(string $variable): string + { + return 'escape($this->getLabel(), '"') . '")} />'; + } + + public function getReactComponents(): array + { + return ['TableCell', 'BooleanEntry']; + } + + public function getTypeScriptType(): string + { + return 'boolean'; + } + + public function getValidValue(): string + { + return 'true'; + } + + public function getOtherValidValue(): string + { + return 'false'; + } + + public function getInvalidValue(): ?string + { + return null; + } +} diff --git a/src/Fields/DateField.php b/src/Fields/DateField.php new file mode 100644 index 0000000..321f1c2 --- /dev/null +++ b/src/Fields/DateField.php @@ -0,0 +1,43 @@ +assertEquals({$input}, {$variable}->{$this->getName()}?->format('Y-m-d'));"; + } + + public function getResourceValue(): string + { + return '$this->' . $this->getName() . "?->format('Y-m-d')"; + } +} diff --git a/src/Fields/DateTimeField.php b/src/Fields/DateTimeField.php new file mode 100644 index 0000000..414a6b1 --- /dev/null +++ b/src/Fields/DateTimeField.php @@ -0,0 +1,65 @@ +add('Javaabu\\Filters\\DateRangeFilter'); + + return "{$class}::make('{$this->getName()}')->label({$this->getLabelCall()})"; + } + + public function getValidValue(): string + { + return "'2024-02-12 14:54'"; + } + + public function getOtherValidValue(): string + { + return "'2025-11-06 09:30'"; + } + + public function getInvalidValue(): ?string + { + return "'ބަދަލު'"; + } + + public function getDatabaseValue(string $input): ?string + { + return null; + } + + public function getSavedValueAssertion(string $variable, string $input, ?Imports $imports = null): ?string + { + return "\$this->assertEquals({$input}, {$variable}->{$this->getName()}?->format('Y-m-d H:i'));"; + } + + public function getResourceValue(): string + { + return '$this->' . $this->getName() . "?->format('Y-m-d H:i')"; + } +} diff --git a/src/Fields/DecimalField.php b/src/Fields/DecimalField.php new file mode 100644 index 0000000..71890bb --- /dev/null +++ b/src/Fields/DecimalField.php @@ -0,0 +1,93 @@ +scale; + } + + public function getMax(): string + { + $digits = max($this->precision - $this->scale, 1); + + // keep it within what a float holds exactly + return Str::repeat('9', min($digits, 15)); + } + + public function getMin(): string + { + return $this->unsigned ? '0' : '-' . $this->getMax(); + } + + public function isSortable(): bool + { + return true; + } + + public function getCast(Imports $imports): ?string + { + return "'decimal:{$this->scale}'"; + } + + protected function fakerCall(): string + { + $max = min((int) $this->getMax(), 10000); + + return "randomFloat({$this->scale}, 0, {$max})"; + } + + protected function stencilClass(): string + { + return 'Javaabu\\Stencil\\Fields\\TextInput'; + } + + protected function stencilModifiers(Imports $imports): array + { + return ['numeric()', "decimals({$this->scale})", "minValue({$this->getMin()})", "maxValue({$this->getMax()})"]; + } + + public function getFilter(Imports $imports): ?string + { + $class = $imports->add('Javaabu\\Filters\\NumberRangeFilter'); + + return "{$class}::make('{$this->getName()}')->label({$this->getLabelCall()})"; + } + + public function getTypeScriptType(): string + { + return 'string'; + } + + public function getValidValue(): string + { + return $this->scale > 0 ? '10.5' : '10'; + } + + public function getOtherValidValue(): string + { + return $this->scale > 0 ? '20.25' : '20'; + } + + public function getInvalidValue(): ?string + { + return "'ބަދަލު'"; + } +} diff --git a/src/Fields/EnumField.php b/src/Fields/EnumField.php new file mode 100644 index 0000000..865e8ff --- /dev/null +++ b/src/Fields/EnumField.php @@ -0,0 +1,188 @@ + $options + */ + public function __construct( + string $name, + protected array $options = [], + protected ?string $enum_class = null, + bool $nullable = false, + mixed $default = null, + bool $unique = false, + ) { + parent::__construct($name, $nullable, $default, $unique); + } + + public function hasEnumClass(): bool + { + return $this->enum_class !== null; + } + + public function getEnumClass(): ?string + { + return $this->enum_class ? ltrim($this->enum_class, '\\') : null; + } + + /** + * The values: the enum cases when the class exists, otherwise the options. + * + * @return array + */ + public function getValues(): array + { + $class = $this->getEnumClass(); + + if ($class && enum_exists($class) && is_subclass_of($class, BackedEnum::class)) { + return array_map(fn (BackedEnum $case) => (string) $case->value, $class::cases()); + } + + return $this->options; + } + + /** + * The value at a position, as PHP code. + */ + protected function valueCode(int $index): string + { + $class = $this->getEnumClass(); + + if ($class && enum_exists($class) && is_subclass_of($class, BackedEnum::class)) { + $cases = $class::cases(); + $case = $cases[$index] ?? $cases[0]; + + return class_basename($class) . "::{$case->name}->value"; + } + + $values = $this->getValues() ?: ['value']; + + return "'" . $this->escape($values[$index] ?? $values[0]) . "'"; + } + + public function isSortable(): bool + { + return true; + } + + public function getCast(Imports $imports): ?string + { + if (! $this->hasEnumClass()) { + return null; + } + + return $imports->add($this->getEnumClass()) . '::class'; + } + + protected function fakerCall(): string + { + return ''; + } + + public function getFactoryValue(Imports $imports): string + { + $optional = $this->isNullable() ? 'optional()->' : ''; + + if ($this->hasEnumClass()) { + $class = $imports->add($this->getEnumClass()); + + return "fake()->{$optional}randomElement({$class}::cases())"; + } + + return "fake()->{$optional}randomElement({$this->optionsArray()})"; + } + + protected function optionsArray(): string + { + return '[' . implode(', ', array_map(fn (string $option) => "'" . $this->escape($option) . "'", $this->getValues())) . ']'; + } + + /** + * Options with labels, for forms and filters. + */ + protected function optionsCode(Imports $imports): string + { + if ($this->hasEnumClass()) { + return $imports->add($this->getEnumClass()) . '::class'; + } + + $options = array_map( + fn (string $option) => "'" . $this->escape($option) . "' => __('" . $this->escape(Str::ucfirst(str_replace('_', ' ', $option))) . "')", + $this->getValues() + ); + + return '[' . implode(', ', $options) . ']'; + } + + protected function stencilClass(): string + { + return 'Javaabu\\Stencil\\Fields\\Select'; + } + + protected function stencilModifiers(Imports $imports): array + { + return ["options({$this->optionsCode($imports)})"]; + } + + public function getFilter(Imports $imports): ?string + { + $class = $imports->add('Javaabu\\Filters\\SelectFilter'); + + return "{$class}::make('{$this->getName()}')->label({$this->getLabelCall()})->options({$this->optionsCode($imports)})"; + } + + public function getResourceValue(): string + { + if (! $this->hasEnumClass()) { + return parent::getResourceValue(); + } + + return '$this->' . $this->getName() . '?->value'; + } + + public function getTypeScriptType(): string + { + $values = $this->getValues(); + + if (! $values) { + return 'string'; + } + + return implode(' | ', array_map(fn (string $value) => '"' . str_replace('"', '\\"', $value) . '"', $values)); + } + + public function getValidValue(): string + { + return $this->valueCode(0); + } + + public function getOtherValidValue(): string + { + return $this->valueCode(1); + } + + public function getInvalidValue(): ?string + { + return "'ބަދަލު'"; + } + + public function getTestSetup(Imports $imports, string $prefix): array + { + if ($this->hasEnumClass()) { + $imports->add($this->getEnumClass()); + } + + return []; + } +} diff --git a/src/Fields/Field.php b/src/Fields/Field.php new file mode 100644 index 0000000..2cca969 --- /dev/null +++ b/src/Fields/Field.php @@ -0,0 +1,341 @@ +name; + } + + /** + * The name of the form state and input. + */ + public function getInputName(): string + { + return $this->getName(); + } + + /** + * The attribute to display (a relation for foreign keys). + */ + public function getDisplayName(): string + { + return $this->getName(); + } + + public function getLabel(): string + { + return Str::ucfirst(str_replace('_', ' ', Str::snake($this->getDisplayName()))); + } + + public function getLabelCall(): string + { + return "__('" . $this->escape($this->getLabel()) . "')"; + } + + /* + |-------------------------------------------------------------------------- + | Column + |-------------------------------------------------------------------------- + */ + + public function isNullable(): bool + { + return $this->nullable; + } + + public function isUnique(): bool + { + return $this->unique; + } + + public function getDefault(): mixed + { + return $this->default; + } + + public function hasDefault(): bool + { + return $this->default !== null; + } + + public function isRequired(): bool + { + return (! $this->isNullable()) && (! $this->hasDefault()); + } + + public function isFillable(): bool + { + return true; + } + + public function isSearchable(): bool + { + return false; + } + + public function isSortable(): bool + { + return false; + } + + /** + * Whether it can be shown in a form by default. + */ + public function isFormField(): bool + { + return true; + } + + /** + * Whether it is shown on pages and sent to the browser. + */ + public function isShown(): bool + { + return true; + } + + /** + * Whether it is a good default column for the index table. + */ + public function isIndexColumn(): bool + { + return true; + } + + /* + |-------------------------------------------------------------------------- + | Model + |-------------------------------------------------------------------------- + */ + + /** + * The cast, as PHP code (a quoted string or a class constant), or null. + */ + public function getCast(Imports $imports): ?string + { + return null; + } + + /* + |-------------------------------------------------------------------------- + | Factory + |-------------------------------------------------------------------------- + */ + + /** + * The faker call after fake()->, e.g. text(255). + */ + abstract protected function fakerCall(): string; + + /** + * The factory value expression. + */ + public function getFactoryValue(Imports $imports): string + { + $call = $this->fakerCall(); + + $unique = $this->isUnique() ? 'unique()->' : ''; + + $optional = $this->isNullable() && ! $this->isUnique() ? 'optional()->' : ''; + + return "fake()->{$unique}{$optional}{$call}"; + } + + /* + |-------------------------------------------------------------------------- + | Form (javaabu/stencil) + |-------------------------------------------------------------------------- + */ + + /** + * The stencil field class. + */ + abstract protected function stencilClass(): string; + + /** + * Calls after make() and label(), e.g. ['maxLength(255)']. + * + * @return array + */ + protected function stencilModifiers(Imports $imports): array + { + return []; + } + + /** + * The stencil field definition, without a trailing comma. + * + * @param array $extra extra calls, e.g. translatable() + */ + public function getStencilField(Imports $imports, array $extra = []): string + { + $class = $imports->add($this->stencilClass()); + + $calls = ["{$class}::make('{$this->getInputName()}')", "label({$this->getLabelCall()})"]; + + if ($this->isRequired() && $this->canBeRequired()) { + $calls[] = 'required()'; + } + + $calls = [...$calls, ...$this->stencilModifiers($imports), ...$extra]; + + if (count($calls) <= 3 && strlen(implode('->', $calls)) <= 100) { + return implode('->', $calls); + } + + $first = array_shift($calls); + + return $first . "\n" . implode("\n", array_map(fn (string $call) => " ->{$call}", $calls)); + } + + protected function canBeRequired(): bool + { + return true; + } + + /* + |-------------------------------------------------------------------------- + | Filters (javaabu/filters) + |-------------------------------------------------------------------------- + */ + + /** + * A filter definition for this field, or null for none. + */ + public function getFilter(Imports $imports): ?string + { + return null; + } + + /* + |-------------------------------------------------------------------------- + | Views + |-------------------------------------------------------------------------- + */ + + public function getBladeCell(string $variable): string + { + return ''; + } + + public function getBladeEntry(string $variable): string + { + return ''; + } + + public function getReactCell(string $variable): string + { + return 'escape($this->getLabel(), '"') . '")} />'; + } + + public function getReactEntry(string $variable): string + { + return 'escape($this->getLabel(), '"') . '")} />'; + } + + /** + * React components from @javaabu/forms the cell and entry use. + * + * @return array + */ + public function getReactComponents(): array + { + return ['TableCell', 'TextEntry']; + } + + /** + * The TypeScript type of the attribute in the resource JSON. + */ + public function getTypeScriptType(): string + { + return 'string'; + } + + /** + * The value in the JSON resource, as PHP code. + */ + public function getResourceValue(): string + { + return '$this->' . $this->getName(); + } + + /* + |-------------------------------------------------------------------------- + | Tests + |-------------------------------------------------------------------------- + */ + + /** + * A valid input value, as PHP code. + */ + abstract public function getValidValue(): string; + + /** + * Another valid input value, for updates. + */ + abstract public function getOtherValidValue(): string; + + /** + * An input value the form rejects, or null when every value is accepted. + */ + public function getInvalidValue(): ?string + { + return '[]'; + } + + /** + * The value expected in the database for an input value, or null when + * it can't be compared in assertDatabaseHas() on every driver. + */ + public function getDatabaseValue(string $input): ?string + { + return $input; + } + + /** + * Code that asserts the saved value, when it isn't compared in the database. + */ + public function getSavedValueAssertion(string $variable, string $input, ?Imports $imports = null): ?string + { + return null; + } + + /** + * Setup code a test needs for the valid values, e.g. a related record. + * + * @return array + */ + public function getTestSetup(Imports $imports, string $prefix): array + { + return []; + } + + protected function escape(string $value, string $quote = "'"): string + { + return str_replace([$quote], ['\\' . $quote], $value); + } +} diff --git a/src/Fields/ForeignKeyField.php b/src/Fields/ForeignKeyField.php new file mode 100644 index 0000000..1847059 --- /dev/null +++ b/src/Fields/ForeignKeyField.php @@ -0,0 +1,152 @@ +related_table; + } + + public function getRelatedKey(): string + { + return $this->related_key; + } + + public function getRelatedNameField(): string + { + return $this->related_name_field; + } + + public function getRelatedModel(): string + { + return $this->models_namespace . '\\' . StringCaser::singularStudly($this->related_table); + } + + public function getRelationName(): string + { + return Naming::belongsTo($this->getName(), $this->related_table); + } + + public function getDisplayName(): string + { + return Str::snake($this->getRelationName()); + } + + public function isSortable(): bool + { + return false; + } + + protected function fakerCall(): string + { + return ''; + } + + public function getFactoryValue(Imports $imports): string + { + $model = $imports->add($this->getRelatedModel()); + + $factory = $this->related_key === 'id' + ? "{$model}::factory()" + : "fn () => {$model}::factory()->create()->{$this->related_key}"; + + if ($this->isNullable()) { + return $this->related_key === 'id' + ? "fn () => fake()->boolean() ? {$model}::factory() : null" + : "fn () => fake()->boolean() ? {$model}::factory()->create()->{$this->related_key} : null"; + } + + return $factory; + } + + protected function stencilClass(): string + { + return 'Javaabu\\Stencil\\Fields\\Select'; + } + + protected function stencilModifiers(Imports $imports): array + { + return ["relationship('{$this->getRelationName()}', '{$this->related_name_field}')", 'searchable()']; + } + + public function getFilter(Imports $imports): ?string + { + $class = $imports->add('Javaabu\\Filters\\SelectFilter'); + + return "{$class}::make('{$this->getName()}')->label({$this->getLabelCall()})->relationship('{$this->getRelationName()}', '{$this->related_name_field}')->searchable()"; + } + + public function getBladeCell(string $variable): string + { + return ''; + } + + public function getBladeEntry(string $variable): string + { + return ''; + } + + public function getReactCell(string $variable): string + { + return 'escape($this->getLabel(), '"') . '")} value={' . $variable . '.' . Str::snake($this->getRelationName()) . '?.' . $this->related_name_field . ' ?? "-"} />'; + } + + public function getReactEntry(string $variable): string + { + return 'escape($this->getLabel(), '"') . '")} value={' . $variable . '.' . Str::snake($this->getRelationName()) . '?.' . $this->related_name_field . ' ?? "-"} />'; + } + + public function getTypeScriptType(): string + { + return $this->related_key === 'id' ? 'number' : 'string'; + } + + public function getValidValue(): string + { + return '$' . Str::snake($this->getRelationName()) . '->' . $this->related_key; + } + + public function getOtherValidValue(): string + { + return '$other_' . Str::snake($this->getRelationName()) . '->' . $this->related_key; + } + + public function getInvalidValue(): ?string + { + return $this->related_key === 'id' ? '-1' : "'ބަދަލު'"; + } + + public function getTestSetup(Imports $imports, string $prefix): array + { + $model = $imports->add($this->getRelatedModel()); + $variable = Str::snake($this->getRelationName()); + + return [ + "\${$variable} = {$model}::factory()->create();", + "\$other_{$variable} = {$model}::factory()->create();", + ]; + } +} diff --git a/src/Fields/IntegerField.php b/src/Fields/IntegerField.php new file mode 100644 index 0000000..e8c8b5e --- /dev/null +++ b/src/Fields/IntegerField.php @@ -0,0 +1,102 @@ + */ + public const array RANGES = [ + 'tinyint' => ['-128', '127', '255'], + 'smallint' => ['-32768', '32767', '65535'], + 'mediumint' => ['-8388608', '8388607', '16777215'], + 'int' => ['-2147483648', '2147483647', '4294967295'], + 'bigint' => ['-9223372036854775808', '9223372036854775807', '18446744073709551615'], + ]; + + public function __construct( + string $name, + bool $nullable = false, + mixed $default = null, + bool $unique = false, + protected string $size = 'int', + protected bool $unsigned = false, + ) { + parent::__construct($name, $nullable, $default, $unique); + } + + public function getMin(): string + { + return $this->unsigned ? '0' : str_replace('9223372036854775808', '9223372036854775807', self::RANGES[$this->size][0]); + } + + public function getMax(): string + { + $max = $this->unsigned ? self::RANGES[$this->size][2] : self::RANGES[$this->size][1]; + + // keep it within PHP_INT_MAX so the rule and the faker work + $limit = (string) PHP_INT_MAX; + + if (strlen($max) > strlen($limit) || (strlen($max) === strlen($limit) && strcmp($max, $limit) > 0)) { + return $limit; + } + + return $max; + } + + public function isSortable(): bool + { + return true; + } + + public function getCast(Imports $imports): ?string + { + return "'integer'"; + } + + protected function fakerCall(): string + { + $max = min((int) $this->getMax(), 10000); + $min = max((int) $this->getMin(), 0); + + return "numberBetween({$min}, {$max})"; + } + + protected function stencilClass(): string + { + return 'Javaabu\\Stencil\\Fields\\TextInput'; + } + + protected function stencilModifiers(Imports $imports): array + { + return ['integer()', "minValue({$this->getMin()})", "maxValue({$this->getMax()})"]; + } + + public function getFilter(Imports $imports): ?string + { + $class = $imports->add('Javaabu\\Filters\\NumberRangeFilter'); + + return "{$class}::make('{$this->getName()}')->label({$this->getLabelCall()})->integer()"; + } + + public function getTypeScriptType(): string + { + return 'number'; + } + + public function getValidValue(): string + { + return '10'; + } + + public function getOtherValidValue(): string + { + return '20'; + } + + public function getInvalidValue(): ?string + { + return "'ބަދަލު'"; + } +} diff --git a/src/Fields/JsonField.php b/src/Fields/JsonField.php new file mode 100644 index 0000000..ab5ac3b --- /dev/null +++ b/src/Fields/JsonField.php @@ -0,0 +1,91 @@ +isNullable() ? 'fake()->optional()->words(3)' : 'fake()->words(3)'; + } + + protected function stencilClass(): string + { + return 'Javaabu\\Stencil\\Fields\\Select'; + } + + protected function stencilModifiers(Imports $imports): array + { + return ['multiple()', 'tags()']; + } + + public function getBladeCell(string $variable): string + { + return '{{ implode(\', \', ' . $variable . '->' . $this->getName() . ' ?? []) }}'; + } + + public function getBladeEntry(string $variable): string + { + return '{{ implode(\', \', ' . $variable . '->' . $this->getName() . ' ?? []) }}'; + } + + public function getReactCell(string $variable): string + { + return 'escape($this->getLabel(), '"') . '")} value={(' . $variable . '.' . $this->getName() . ' ?? []).join(", ")} />'; + } + + public function getReactEntry(string $variable): string + { + return 'escape($this->getLabel(), '"') . '")} value={(' . $variable . '.' . $this->getName() . ' ?? []).join(", ")} />'; + } + + public function getTypeScriptType(): string + { + return 'string[]'; + } + + public function getValidValue(): string + { + return "['ރަށް', 'atoll']"; + } + + public function getOtherValidValue(): string + { + return "['ފަޅު']"; + } + + public function getInvalidValue(): ?string + { + return "'ބަދަލު'"; + } + + public function getDatabaseValue(string $input): ?string + { + return null; + } + + public function getSavedValueAssertion(string $variable, string $input, ?Imports $imports = null): ?string + { + return "\$this->assertEquals({$input}, {$variable}->{$this->getName()});"; + } +} diff --git a/src/Fields/PasswordField.php b/src/Fields/PasswordField.php new file mode 100644 index 0000000..bd6104e --- /dev/null +++ b/src/Fields/PasswordField.php @@ -0,0 +1,78 @@ +add($this->stencilClass()); + + return "{$class}::make('{$this->getName()}')\n" + . " ->label({$this->getLabelCall()})\n" + . " ->password()\n" + . " ->required(fn (string \$operation) => \$operation === 'create')\n" + . " ->minLength(8)\n" + . " ->maxLength(255)\n" + . ' // left empty when editing: the password stays' . "\n" + . ' ->dehydrated(fn (?string $state) => filled($state))'; + } + + public function getValidValue(): string + { + return "'Ab12cd34ef!'"; + } + + public function getOtherValidValue(): string + { + return "'Zy98xw76vu!'"; + } + + public function getInvalidValue(): ?string + { + return "'short'"; + } + + public function getDatabaseValue(string $input): ?string + { + return null; + } + + public function getSavedValueAssertion(string $variable, string $input, ?Imports $imports = null): ?string + { + $hash = $imports ? $imports->add('Illuminate\\Support\\Facades\\Hash') : '\\Illuminate\\Support\\Facades\\Hash'; + + return "\$this->assertTrue({$hash}::check({$input}, {$variable}->{$this->getName()}));"; + } +} diff --git a/src/Fields/StringField.php b/src/Fields/StringField.php new file mode 100644 index 0000000..ab3bbcd --- /dev/null +++ b/src/Fields/StringField.php @@ -0,0 +1,112 @@ +max; + } + + /** + * email, tel, url or text, from the column name. + */ + public function getKind(): string + { + $name = $this->getName(); + + if ($name === 'email' || Str::endsWith($name, '_email')) { + return 'email'; + } + + if (Str::contains($name, ['phone', 'mobile']) || $name === 'tel') { + return 'tel'; + } + + if (in_array($name, ['url', 'website', 'link'], true) || Str::endsWith($name, ['_url', '_website'])) { + return 'url'; + } + + return 'text'; + } + + public function isSearchable(): bool + { + return true; + } + + public function isSortable(): bool + { + return true; + } + + protected function fakerCall(): string + { + $max = $this->getMax(); + + return match ($this->getKind()) { + 'email' => 'safeEmail()', + 'tel' => "numerify('7######')", + 'url' => 'url()', + default => $max < 5 ? "regexify('[A-Z]{{$max}}')" : 'text(' . min($max, 50) . ')', + }; + } + + protected function stencilClass(): string + { + return 'Javaabu\\Stencil\\Fields\\TextInput'; + } + + protected function stencilModifiers(Imports $imports): array + { + $modifiers = []; + + $kind = $this->getKind(); + + if ($kind !== 'text') { + $modifiers[] = "{$kind}()"; + } + + $modifiers[] = "maxLength({$this->getMax()})"; + + if ($this->isUnique()) { + $modifiers[] = 'unique()'; + } + + return $modifiers; + } + + public function getValidValue(): string + { + return match ($this->getKind()) { + 'email' => "'ali@example.com'", + 'tel' => "'7771234'", + 'url' => "'https://example.com'", + default => $this->getMax() < 5 ? "'" . substr('ABCD', 0, $this->getMax()) . "'" : "'ރަސްމީ'", + }; + } + + public function getOtherValidValue(): string + { + return match ($this->getKind()) { + 'email' => "'aisha@example.com'", + 'tel' => "'9601234'", + 'url' => "'https://example.mv'", + default => $this->getMax() < 5 ? "'" . substr('WXYZ', 0, $this->getMax()) . "'" : "'ދިވެހި'", + }; + } +} diff --git a/src/Fields/TextField.php b/src/Fields/TextField.php new file mode 100644 index 0000000..398cbf8 --- /dev/null +++ b/src/Fields/TextField.php @@ -0,0 +1,76 @@ +max && $this->max < 4294967295) { + $modifiers[] = "maxLength({$this->max})"; + } + + $modifiers[] = 'columnSpanFull()'; + + return $modifiers; + } + + public function getBladeCell(string $variable): string + { + return ''; + } + + public function getBladeEntry(string $variable): string + { + return ''; + } + + public function getReactEntry(string $variable): string + { + return 'escape($this->getLabel(), '"') . '")} multiline />'; + } + + public function getValidValue(): string + { + return "'ދިވެހިރާއްޖޭގެ ޖުމްހޫރިއްޔާ'"; + } + + public function getOtherValidValue(): string + { + return "'Republic of Maldives'"; + } +} diff --git a/src/Fields/TimeField.php b/src/Fields/TimeField.php new file mode 100644 index 0000000..ae31cab --- /dev/null +++ b/src/Fields/TimeField.php @@ -0,0 +1,48 @@ +assertEquals({$input}, substr((string) {$variable}->{$this->getName()}, 0, 5));"; + } +} diff --git a/src/Fields/YearField.php b/src/Fields/YearField.php new file mode 100644 index 0000000..39c575d --- /dev/null +++ b/src/Fields/YearField.php @@ -0,0 +1,60 @@ +add('Javaabu\\Filters\\NumberRangeFilter'); + + return "{$class}::make('{$this->getName()}')->label({$this->getLabelCall()})->integer()"; + } + + public function getTypeScriptType(): string + { + return 'number'; + } + + public function getValidValue(): string + { + return '2024'; + } + + public function getOtherValidValue(): string + { + return '2025'; + } + + public function getInvalidValue(): ?string + { + return '1800'; + } +} diff --git a/src/Generators/ApiControllerGenerator.php b/src/Generators/ApiControllerGenerator.php deleted file mode 100644 index 85784a8..0000000 --- a/src/Generators/ApiControllerGenerator.php +++ /dev/null @@ -1,113 +0,0 @@ -getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - - $relations = []; - - $filters = [ - $this->getKeyName(), - ]; - - $sorts = [ - $this->getKeyName(), - ]; - - if ($this->hasTimestamps()) { - $sorts[] = 'created_at'; - $sorts[] = 'updated_at'; - } - - /** - * @var string $column - * @var Field $field - */ - foreach ($this->getFields() as $column => $field) { - if ($field->isSortable()) { - $sorts[] = $column; - } - - if ($field->isSearchable()) { - $filters[] = $column; - } - - if ($field instanceof ForeignKeyField) { - $relations[] = $field->getRelationName(); - } - } - - $template = $renderer->appendMultipleContent([ - [ - 'search' => '{{tableName}}', - 'keep_search' => false, - 'content' => $this->getTable(), - ], - [ - 'search' => '{{defaultSort}}', - 'keep_search' => false, - 'content' => $this->hasTimestamps() ? 'created_at' : $this->getKeyName(), - ], - [ - 'search' => $renderer->addIndentation("// allowed sorts\n", 3), - 'keep_search' => false, - 'content' => $this->renderStringList($sorts), - ], - - [ - 'search' => $renderer->addIndentation("// allowed includes\n", 3), - 'keep_search' => false, - 'content' => $this->renderStringList($relations), - ], - [ - 'search' => $renderer->addIndentation("// allowed filters\n", 3), - 'keep_search' => false, - 'content' => $this->renderStringList($filters), - ], - ], $template); - - return $template; - } - - /** - * Render the routes - */ - public function renderRoutes(): string - { - $stub = 'generators::routes/_api.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } - - /** - * Render a string list - */ - public function renderStringList(array $list, int $indent = 3): string - { - $output = ''; - - $renderer = $this->getRenderer(); - - foreach ($list as $item) { - $output .= $renderer->addIndentation("'$item',\n", $indent); - } - - return $output; - } -} diff --git a/src/Generators/ApiTestGenerator.php b/src/Generators/ApiTestGenerator.php deleted file mode 100644 index 5163819..0000000 --- a/src/Generators/ApiTestGenerator.php +++ /dev/null @@ -1,34 +0,0 @@ -getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - - return $renderer->appendMultipleContent([ - [ - 'search' => '{{keyName}}', - 'keep_search' => false, - 'content' => $this->getKeyName(), - ], - [ - 'search' => '{{nameField}}', - 'keep_search' => false, - 'content' => $this->getNameField(), - ], - ], $template); - } -} diff --git a/src/Generators/Auth/AuthConfigGenerator.php b/src/Generators/Auth/AuthConfigGenerator.php index 1b21f85..9037648 100644 --- a/src/Generators/Auth/AuthConfigGenerator.php +++ b/src/Generators/Auth/AuthConfigGenerator.php @@ -2,110 +2,55 @@ namespace Javaabu\Generators\Generators\Auth; -use Faker\Generator; -use Illuminate\Support\Str; -use Javaabu\Generators\FieldTypes\Field; -use Javaabu\Generators\FieldTypes\ForeignKeyField; -use Javaabu\Generators\Generators\BaseGenerator; -use Javaabu\Generators\Generators\Concerns\GeneratesFactory; -use Javaabu\GeneratorHelpers\StringCaser; - -class AuthConfigGenerator extends BaseAuthGenerator +use Javaabu\Generators\Generators\GeneratedFile; +use Javaabu\Generators\Generators\Generator; +use Javaabu\Generators\Support\PhpFile; + +/** + * The user type's session guard, user provider and password broker in + * config/auth.php. Every broker shares the password_reset_tokens table. + */ +class AuthConfigGenerator extends Generator { - public function stubsToRender(): array + public function files(): array { - $stubs = [ - 'renderGuards' => 'guards', - 'renderProviders' => 'providers', - 'renderPasswords' => 'passwords', - 'renderPassportGuards' => 'passport-guards', - ]; - - if ($this->shouldRenderDefaultGuard()) { - $stubs['renderDefaultGuard'] = 'default-guard'; - $stubs['renderDefaultPasswords'] = 'default-passwords'; - } - - return $stubs; + return [new GeneratedFile(config_path('auth.php'), $this->renderConfig(), fn (string $code) => $this->mergeInto($code), 'auth config')]; } - - /** - * Render the views - */ - public function render(): string + protected function types(): UserTypeGenerator { - - $output = ''; - - $views = $this->stubsToRender(); - - foreach ($views as $method => $file_name) { - $output .= "// $file_name\n"; - $output .= $this->{$method}(); - } - - return $output; + return new UserTypeGenerator($this->resource); } - public function renderGuards(): string + public function mergeInto(string $code): string { - $stub = 'generators::config/auth/_guards.stub'; + $types = $this->types(); + $provider = $this->resource->table(); - $renderer = $this->getRenderer(); + $code = PhpFile::addImport($code, $this->resource->modelClass()); + $code = PhpFile::addArrayEntry($code, 'guards', $types->guard(), $this->guardEntry()); + $code = PhpFile::addArrayEntry($code, 'providers', $provider, $this->providerEntry()); - return $renderer->replaceStubNames($stub, $this->getTable()); + return PhpFile::addArrayEntry($code, 'passwords', $types->broker(), $this->brokerEntry()); } - public function renderProviders(): string + public function renderConfig(): string { - $stub = 'generators::config/auth/_providers.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); + return "// 'guards'\n{$this->guardEntry()}\n\n// 'providers'\n{$this->providerEntry()}\n\n// 'passwords'\n{$this->brokerEntry()}"; } - public function renderPasswords(): string + protected function guardEntry(): string { - $stub = 'generators::config/auth/_passwords.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); + return "'{$this->types()->guard()}' => [\n 'driver' => 'session',\n 'provider' => '{$this->resource->table()}',\n],"; } - public function renderPassportGuards(): string + protected function providerEntry(): string { - $stub = 'generators::config/auth/_passport-guards.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } - - public function renderDefaultGuard(): string - { - $stub = 'generators::config/auth/_default-guard.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); + return "'{$this->resource->table()}' => [\n 'driver' => 'eloquent',\n 'model' => {$this->resource->model()}::class,\n],"; } - public function renderDefaultPasswords(): string + protected function brokerEntry(): string { - $stub = 'generators::config/auth/_default-passwords.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } - - public function shouldRenderDefaultGuard(): bool - { - $default_guard = config('auth.defaults.guard'); - - return is_null($default_guard) || in_array($default_guard, ['web', 'web_admin']); + return "'{$this->types()->broker()}' => [\n 'provider' => '{$this->resource->table()}',\n 'table' => 'password_reset_tokens',\n 'expire' => 60,\n 'throttle' => 60,\n],"; } } diff --git a/src/Generators/Auth/AuthFactoryGenerator.php b/src/Generators/Auth/AuthFactoryGenerator.php deleted file mode 100644 index b7981d6..0000000 --- a/src/Generators/Auth/AuthFactoryGenerator.php +++ /dev/null @@ -1,43 +0,0 @@ -faker = app(Generator::class); - } - - public function render(): string - { - $template = $this->renderFactory(); - - $renderer = $this->getRenderer(); - - return $renderer->appendMultipleContent([ - [ - 'search' => '{{password}}', - 'keep_search' => false, - 'content' => $this->getDefaultPassword(), - ], - ], $template); - } -} diff --git a/src/Generators/Auth/AuthMigrationGenerator.php b/src/Generators/Auth/AuthMigrationGenerator.php new file mode 100644 index 0000000..d123451 --- /dev/null +++ b/src/Generators/Auth/AuthMigrationGenerator.php @@ -0,0 +1,29 @@ +table}_table.php"; + + return new GeneratedFile(database_path("migrations/{$name}"), $this->render(), description: 'migration'); + } + + public function render(): string + { + return Stub::make('auth/migration.stub')->with(['table' => $this->table])->render(); + } +} diff --git a/src/Generators/Auth/AuthModelGenerator.php b/src/Generators/Auth/AuthModelGenerator.php deleted file mode 100644 index 2b831b2..0000000 --- a/src/Generators/Auth/AuthModelGenerator.php +++ /dev/null @@ -1,69 +0,0 @@ -getAuthColumnFillables(); - - $fillable = ''; - - foreach ($auth_fillables as $column) { - if ($this->isAuthColumn($column)) { - $fillable .= $this->renderModelFillable($column); - } - } - - return $fillable; - } - - /** - * Render the views - */ - public function render(): string - { - $template = $this->renderModel(); - - $renderer = $this->getRenderer(); - - return $renderer->replaceNames($this->getAuthName(), $template, 'AuthName'); - } -} diff --git a/src/Generators/Auth/AuthPasswordResetsGenerator.php b/src/Generators/Auth/AuthPasswordResetsGenerator.php deleted file mode 100644 index 6522e07..0000000 --- a/src/Generators/Auth/AuthPasswordResetsGenerator.php +++ /dev/null @@ -1,26 +0,0 @@ -format('Y_m_d_His'); - - return $date . '_create_' . $this->getMorph() . '_password_reset_tokens_table'; - } - - - public function render(): string - { - $stub = 'generators::migrations/create_auth_password_reset_tokens_table.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } -} diff --git a/src/Generators/Auth/AuthPermissionsGenerator.php b/src/Generators/Auth/AuthPermissionsGenerator.php deleted file mode 100644 index abfdb6f..0000000 --- a/src/Generators/Auth/AuthPermissionsGenerator.php +++ /dev/null @@ -1,28 +0,0 @@ -renderPermissions(); - } -} diff --git a/src/Generators/Auth/AuthPolicyGenerator.php b/src/Generators/Auth/AuthPolicyGenerator.php deleted file mode 100644 index 53d5375..0000000 --- a/src/Generators/Auth/AuthPolicyGenerator.php +++ /dev/null @@ -1,20 +0,0 @@ -renderPolicy(); - } -} diff --git a/src/Generators/Auth/AuthRequestGenerator.php b/src/Generators/Auth/AuthRequestGenerator.php deleted file mode 100644 index 934066c..0000000 --- a/src/Generators/Auth/AuthRequestGenerator.php +++ /dev/null @@ -1,53 +0,0 @@ -requiresEmail()) || (! $this->requiresPassword())) { - $password = $this->requiresPassword() ? 'true' : 'false'; - $email = $this->requiresEmail() ? 'true' : 'false'; - - return "\$this->baseRules($password, $email)"; - } - - return '$this->baseRules()'; - } - - protected function getRequestRulesBodyStub(): string - { - if (! $this->hasAdditionalAttributes()) { - return 'generators::Requests/_authBlankRulesBody.stub'; - } - - return 'generators::Requests/_authRulesBody.stub'; - } - - /** - * Render the request - */ - public function render(): string - { - $template = $this->renderRequest(); - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceNames($this->getAuthName(), $template, 'AuthName'); - - return $renderer->appendMultipleContent([ - [ - 'search' => '{{baseRule}}', - 'keep_search' => false, - 'content' => $this->renderBaseRulesStatement(), - ], - ], $template); - } -} diff --git a/src/Generators/Auth/AuthRoutesGenerator.php b/src/Generators/Auth/AuthRoutesGenerator.php new file mode 100644 index 0000000..2f27c5c --- /dev/null +++ b/src/Generators/Auth/AuthRoutesGenerator.php @@ -0,0 +1,70 @@ +resource->user_type; + + $web = new GeneratedFile(base_path('routes/web.php'), $this->renderAuthRoutes(), function (string $code) use ($type) { + if (str_contains($code, "Route::authRoutes('{$type}'")) { + return $code; + } + + if (trim($code) === '') { + $code = "renderAuthRoutes() . "\n"; + }, 'auth routes'); + + $routes = new RoutesGenerator($this->resource); + + $admin = new GeneratedFile($routes->routesPath(), $this->renderAccountActions(), function (string $code) use ($routes) { + if (trim($code) === '') { + $code = $routes->newRoutesFile(); + } + + if (! str_contains($code, RoutesGenerator::MARKER)) { + return rtrim($code) . "\n\n" . $this->renderAccountActions() . "\n"; + } + + return PhpFile::insertBeforeMarker($code, RoutesGenerator::MARKER, $this->renderAccountActions()); + }, 'account actions'); + + return [$web, $admin]; + } + + public function prefix(): string + { + return StringCaser::kebab((string) $this->resource->user_type); + } + + public function renderAuthRoutes(): string + { + $type = (string) $this->resource->user_type; + + return "// Login, registration, password resets and account pages of {$this->resource->pluralLower()} (javaabu/auth)\n" + . "Route::authRoutes('{$type}', ['prefix' => '{$this->prefix()}']);"; + } + + public function renderAccountActions(): string + { + $type = (string) $this->resource->user_type; + + return "// Unlock, reset two-factor, require a new password and log out {$this->resource->pluralLower()}\n" + . "Route::userAccountActions('{$type}', ['prefix' => '{$this->resource->kebab()}']);"; + } +} diff --git a/src/Generators/Auth/BaseAuthGenerator.php b/src/Generators/Auth/BaseAuthGenerator.php deleted file mode 100644 index bb53d0c..0000000 --- a/src/Generators/Auth/BaseAuthGenerator.php +++ /dev/null @@ -1,136 +0,0 @@ -auth_name = StringCaser::snake($auth_name) ?: StringCaser::singularSnake($table); - - parent::__construct($table); - - $this->password_required = (bool) $this->getField('password')?->isRequired(); - $this->email_required = (bool) $this->getField('email')?->isRequired(); - - // remove auth columns - $this->removeAuthColumns($table, $columns); - - $this->default_password = $this->generateDefaultPassword(); - } - - /** - * Get the default password - */ - public function getDefaultPassword(): string - { - return $this->default_password; - } - - /** - * Get the default password - */ - public function generateDefaultPassword(): string - { - $file_path = database_path('seeders/DefaultUsersSeeder.php'); - $default_password = ''; - - if (file_exists($file_path)) { - $default_password = $this->extractDefaultPassword($file_path); - } - - return $default_password ?: StringCaser::singularStudly($this->getTable()) . '@123456'; - } - - /** - * Extract the default password - */ - public function extractDefaultPassword(string $file): string - { - $contents = file_get_contents($file); - - $matches = []; - - if (preg_match('/\'password\' => \'(.+)\'/', $contents, $matches)) { - return $matches[1]; - } - - return ''; - } - - /** - * Check if is an auth column - */ - public function isAuthColumn(string $column): bool - { - return in_array($column, config('generators.auth_skip_columns')); - } - - /** - * Check if has additional columns - */ - public function hasAdditionalAttributes(): bool - { - return ! empty($this->getFields()); - } - - public function requiresPassword(): bool - { - return $this->password_required; - } - - public function requiresEmail(): bool - { - return $this->email_required; - } - - /** - * Remove auth columns from - */ - protected function removeAuthColumns(string $table, array $columns): void - { - $this->columns = array_diff($columns, config('generators.auth_skip_columns')); - - // get all columns if columns not provided - if (! $columns) { - $columns = array_keys($this->getFields()); - } - - $fields_to_keep = array_diff($columns, config('generators.auth_skip_columns')); - - $this->fields = Arr::only($this->getFields(), $fields_to_keep); - } - - public function getAuthName(): string - { - return $this->auth_name; - } - - public function getWebGuardName(): string - { - return 'web_' . $this->getMorph(); - } - - public function getApiGuardName(): string - { - return 'api_' . $this->getMorph(); - } - - public function getNamespace(): string - { - return StringCaser::studly($this->getAuthName()); - } -} diff --git a/src/Generators/Auth/Controllers/AuthConfirmPasswordControllerGenerator.php b/src/Generators/Auth/Controllers/AuthConfirmPasswordControllerGenerator.php deleted file mode 100644 index ccc75cf..0000000 --- a/src/Generators/Auth/Controllers/AuthConfirmPasswordControllerGenerator.php +++ /dev/null @@ -1,20 +0,0 @@ -renderAuthControllerFromStub('generators::Controllers/Auth/ConfirmPasswordController.stub'); - } -} diff --git a/src/Generators/Auth/Controllers/AuthControllerGenerator.php b/src/Generators/Auth/Controllers/AuthControllerGenerator.php deleted file mode 100644 index 02c214b..0000000 --- a/src/Generators/Auth/Controllers/AuthControllerGenerator.php +++ /dev/null @@ -1,57 +0,0 @@ -getAuthColumnOrderbys(), [$this, 'isAuthColumn'])); - - return $orderbys; - } - - - - /** - * Render the views - */ - public function render(): string - { - return $this->renderController(); - } -} diff --git a/src/Generators/Auth/Controllers/AuthForgotPasswordControllerGenerator.php b/src/Generators/Auth/Controllers/AuthForgotPasswordControllerGenerator.php deleted file mode 100644 index ef8647a..0000000 --- a/src/Generators/Auth/Controllers/AuthForgotPasswordControllerGenerator.php +++ /dev/null @@ -1,19 +0,0 @@ -renderAuthControllerFromStub('generators::Controllers/Auth/ForgotPasswordController.stub'); - } -} diff --git a/src/Generators/Auth/Controllers/AuthHomeControllerGenerator.php b/src/Generators/Auth/Controllers/AuthHomeControllerGenerator.php deleted file mode 100644 index 066ea68..0000000 --- a/src/Generators/Auth/Controllers/AuthHomeControllerGenerator.php +++ /dev/null @@ -1,29 +0,0 @@ -getNamespace(); - } - - public function getControllerName(): string - { - return 'HomeController'; - } - - /** - * Render the views - */ - public function render(): string - { - - return $this->renderAuthControllerFromStub('generators::Controllers/Auth/HomeController.stub'); - } -} diff --git a/src/Generators/Auth/Controllers/AuthLoginControllerGenerator.php b/src/Generators/Auth/Controllers/AuthLoginControllerGenerator.php deleted file mode 100644 index fcc8fc0..0000000 --- a/src/Generators/Auth/Controllers/AuthLoginControllerGenerator.php +++ /dev/null @@ -1,19 +0,0 @@ -renderAuthControllerFromStub('generators::Controllers/Auth/LoginController.stub'); - } -} diff --git a/src/Generators/Auth/Controllers/AuthRegisterControllerGenerator.php b/src/Generators/Auth/Controllers/AuthRegisterControllerGenerator.php deleted file mode 100644 index 74312b4..0000000 --- a/src/Generators/Auth/Controllers/AuthRegisterControllerGenerator.php +++ /dev/null @@ -1,93 +0,0 @@ -getFields(); - $assignments = []; - $renderer = $this->getRenderer(); - - /** - * @var string $column - * @var Field $field - */ - foreach ($fields as $column => $field) { - if ($field->isRequired()) { - $assignments[] = $renderer->addIndentation($this->renderRegisterAssignment($column), 2); - } - } - - return implode("\n", $assignments); - } - - public function renderRegisterAssignment(string $column): string - { - $field = $this->getField($column); - - return '$' . $this->getMorph() . '->' . $field->renderAssignment('$data[', ']') . ';'; - } - - /** - * Render the views - */ - public function render(): string - { - - $template = $this->renderRequest(); - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceNames($this->getAuthName(), $template, 'AuthName'); - - $assignments = $this->renderRegisterAssignments(); - - return $renderer->appendMultipleContent([ - [ - 'search' => $renderer->addIndentation("// assignments\n", 2), - 'keep_search' => false, - 'content' => $assignments ? $assignments . "\n" : '', - ], - ], $template); - } -} diff --git a/src/Generators/Auth/Controllers/AuthResetPasswordControllerGenerator.php b/src/Generators/Auth/Controllers/AuthResetPasswordControllerGenerator.php deleted file mode 100644 index e4005ba..0000000 --- a/src/Generators/Auth/Controllers/AuthResetPasswordControllerGenerator.php +++ /dev/null @@ -1,19 +0,0 @@ -renderAuthControllerFromStub('generators::Controllers/Auth/ResetPasswordController.stub'); - } -} diff --git a/src/Generators/Auth/Controllers/AuthUpdatePasswordControllerGenerator.php b/src/Generators/Auth/Controllers/AuthUpdatePasswordControllerGenerator.php deleted file mode 100644 index 3351f6a..0000000 --- a/src/Generators/Auth/Controllers/AuthUpdatePasswordControllerGenerator.php +++ /dev/null @@ -1,19 +0,0 @@ -renderAuthControllerFromStub('generators::Controllers/Auth/UpdatePasswordController.stub'); - } -} diff --git a/src/Generators/Auth/Controllers/AuthVerificationControllerGenerator.php b/src/Generators/Auth/Controllers/AuthVerificationControllerGenerator.php deleted file mode 100644 index 0cc95d1..0000000 --- a/src/Generators/Auth/Controllers/AuthVerificationControllerGenerator.php +++ /dev/null @@ -1,19 +0,0 @@ -renderAuthControllerFromStub('generators::Controllers/Auth/VerificationController.stub'); - } -} diff --git a/src/Generators/Auth/Controllers/BaseAuthControllerGenerator.php b/src/Generators/Auth/Controllers/BaseAuthControllerGenerator.php deleted file mode 100644 index 7d18765..0000000 --- a/src/Generators/Auth/Controllers/BaseAuthControllerGenerator.php +++ /dev/null @@ -1,12 +0,0 @@ -renderRegistration(), function (string $code) { + $code = PhpFile::addImport($code, 'Javaabu\\Auth\\UserTypes'); + $code = PhpFile::addImport($code, $this->resource->modelClass()); + + return PhpFile::appendToMethod($code, 'boot', $this->renderRegistration()); + }, 'user type')]; + } + + public function type(): string + { + return (string) $this->resource->user_type; + } + + public function guard(): string + { + return (string) ($this->resource->user_guard ?: $this->type()); + } + + public function broker(): string + { + return StringCaser::pluralSnake($this->type()); + } + + public function renderRegistration(): string + { + $resource = $this->resource; + + return "// The {$this->type()} user type: login, registration, password resets and account pages (javaabu/auth)\n" + . "UserTypes::register('{$this->type()}', [\n" + . " 'model' => {$resource->model()}::class,\n" + . " 'guard' => '{$this->guard()}',\n" + . " 'passwords' => '{$this->broker()}',\n" + . ']);'; + } +} diff --git a/src/Generators/BaseGenerator.php b/src/Generators/BaseGenerator.php deleted file mode 100644 index 8bfbe10..0000000 --- a/src/Generators/BaseGenerator.php +++ /dev/null @@ -1,276 +0,0 @@ -table = $table; - $this->columns = $columns; - - /** @var TableProperties $table_properties */ - $table_properties = app()->make(SchemaResolverInterface::class, compact('table', 'columns'))->resolve(); - - $this->key_name = $table_properties->getKeyName(); - $this->fields = $table_properties->getFields(); - $this->soft_deletes = $table_properties->hasSoftDeletes(); - $this->timestamps = $table_properties->hasTimestamps(); - - $this->renderer = app()->make(StubRenderer::class); - } - - public abstract function render(): string; - - public function getField(string $column): ?Field - { - return $this->fields[$column] ?? null; - } - - public function isNullable(string $column): bool - { - return $this->getField($column)->isNullable(); - } - - public function hasDefault(string $column): bool - { - return $this->getField($column)->hasDefault(); - } - - public function getRenderer(): StubRenderer - { - return $this->renderer; - } - - public function getFields(): array - { - return $this->fields; - } - - public function getTable(): string - { - return $this->table; - } - - public function getMorph(): string - { - return StringCaser::singularSnake($this->getTable()); - } - - public function getModelClass(): string - { - return StringCaser::singularStudly($this->getTable()); - } - - /** - * @return bool - */ - public function hasSoftDeletes(): bool - { - return $this->soft_deletes; - } - - /** - * @return bool - */ - public function hasTimestamps(): bool - { - return $this->timestamps; - } - - /** - * Check if has any fillable - */ - public function hasAnyFillable(): bool - { - $fields = $this->getFields(); - - /** - * @var string $column - * @var Field $field - */ - foreach ($fields as $column => $field) { - if ($field->isFillable()) { - return true; - } - } - - return false; - } - - /** - * Get the fillable attributes - */ - public function getFillableAttributes(): array - { - $fillable = []; - - $fields = $this->getFields(); - - /** - * @var string $column - * @var Field $field - */ - foreach ($fields as $column => $field) { - if ($field->isFillable()) { - $fillable[] = $column; - } - } - - return $fillable; - } - - /** - * Get the searchable attributes - */ - public function getSearchableAttributes(): array - { - $fillable = []; - - $fields = $this->getFields(); - - /** - * @var string $column - * @var Field $field - */ - foreach ($fields as $column => $field) { - if ($field->isSearchable()) { - $fillable[] = $column; - } - } - - return $fillable; - } - - /** - * Get the foreign keys - */ - public function getForeignKeyAttributes(): array - { - $fillable = []; - - $fields = $this->getFields(); - - /** - * @var string $column - * @var Field $field - */ - foreach ($fields as $column => $field) { - if ($field instanceof ForeignKeyField) { - $fillable[] = $column; - } - } - - return $fillable; - } - - /** - * Get the icon provider - */ - public function getIconProvider(): BaseIconProvider - { - $provider = config('generators.icon_provider'); - - return new $provider; - } - - /** - * Get the sidebar icon prefix - */ - public function getSidebarIconPrefix(): ?string - { - return config('generators.sidebar_icon_prefix'); - } - - /** - * Get the icon - */ - public function getIcon(bool $format = true): string - { - $provider = $this->getIconProvider(); - $icon = $provider->findIconFor($this->getTable()); - - return $format ? $provider->formatIcon($icon) : $icon; - } - - /** - * Get the key name - */ - public function getKeyName(): string - { - return $this->key_name; - } - - /** - * Get field count - */ - public function fieldsCount(): int - { - return count($this->getFields()); - } - - /** - * Check if the name field is in the columns - */ - public function isNameFieldIncludedInColumns(): bool - { - $name_field = $this->getNameField(); - - return array_key_exists($name_field, $this->getFields()); - } - - /** - * Get the name field - */ - public function getNameField(): string - { - // check if any reserved names exists - $candidate_name_fields = ['name', 'title']; - - foreach ($candidate_name_fields as $column) { - if ($this->getField($column) instanceof StringField) { - return $column; - } - } - - // look for the first string field - $fields = $this->getFields(); - - foreach ($fields as $column => $field) { - if ($field instanceof StringField) { - return $column; - } - } - - // default to the key name - return $this->getKeyName(); - } - - /** - * Get the admin link name label - */ - public function getNameLabel(): string - { - return StringCaser::title($this->getNameField()); - } -} diff --git a/src/Generators/BladeViewsGenerator.php b/src/Generators/BladeViewsGenerator.php new file mode 100644 index 0000000..95f8ae5 --- /dev/null +++ b/src/Generators/BladeViewsGenerator.php @@ -0,0 +1,264 @@ +resource; + + return [ + new GeneratedFile($resource->viewPath('index'), $this->renderIndex(), description: 'index view'), + new GeneratedFile($resource->viewPath('create'), $this->renderCreate(), description: 'create view'), + new GeneratedFile($resource->viewPath('edit'), $this->renderEdit(), description: 'edit view'), + new GeneratedFile($resource->viewPath('show'), $this->renderShow(), description: 'show view'), + ]; + } + + /** + * @return array + */ + protected function common(): array + { + $resource = $this->resource; + + return [ + 'layout' => (string) config('generators.blade.layout', 'layouts.admin'), + 'section' => (string) config('generators.blade.section', 'content'), + 'modelClass' => '\\' . $resource->modelClass(), + 'plural' => $resource->pluralVariable(), + 'variable' => $resource->variable(), + 'pluralLower' => $this->escape($resource->pluralLower()), + 'singularLabel' => $this->escape($resource->singularLabel()), + 'nameField' => $resource->nameField(), + ]; + } + + public function renderIndex(): string + { + $resource = $this->resource; + $variable = '$' . $resource->variable(); + $sortable = (new FiltersGenerator($resource))->sortableColumns(); + + $headers = []; + $cells = []; + + foreach ($resource->indexFields() as $column => $field) { + $label = $field->getLabelCall(); + + $headers[] = in_array($column, $sortable, true) + ? "" + : ""; + + if ($column === $resource->nameField()) { + $cells[] = "\n" + . " @can('view', {$variable})\n" + . " route('show', $variable)} }}\" class=\"font-medium hover:underline\">{{ {$variable}->{$column} }}\n" + . " @else\n" + . " {{ {$variable}->{$column} }}\n" + . " @endcan\n" + . ''; + + continue; + } + + $cells[] = $field->getBladeCell($variable); + } + + foreach ($resource->indexRelations() as $relation) { + $label = $this->relationLabel($relation); + + $headers[] = ""; + $cells[] = "{$relation->name}->pluck('{$this->relationNameField($relation)}')->all()\" />"; + } + + return Stub::make('views/blade/index.blade.stub')->with([ + ...$this->common(), + 'title' => $this->escape($resource->pluralLabel()), + 'newLabel' => $this->escape('New ' . $resource->singularLower()), + 'createRoute' => $resource->route('create'), + 'breadcrumbs' => $this->breadcrumbs(false), + 'headers' => implode("\n", $headers), + 'cells' => implode("\n", $cells), + 'columns' => (string) (count($headers) + 1), + ])->render(); + } + + public function renderCreate(): string + { + $resource = $this->resource; + + return Stub::make('views/blade/create.blade.stub')->with([ + ...$this->common(), + 'title' => $this->escape('New ' . $resource->singularLower()), + 'breadcrumbs' => $this->breadcrumbs(), + ])->render(); + } + + public function renderEdit(): string + { + $resource = $this->resource; + + return Stub::make('views/blade/edit.blade.stub')->with([ + ...$this->common(), + 'title' => $this->escape('Edit ' . $resource->singularLower()), + 'showRoute' => $resource->route('show', '$' . $resource->variable()), + 'breadcrumbs' => $this->breadcrumbs(), + ])->render(); + } + + public function renderShow(): string + { + $resource = $this->resource; + $variable = '$' . $resource->variable(); + + $entries = []; + + foreach ($resource->showFields() as $field) { + $entries[] = $field->getBladeEntry($variable); + } + + foreach ($resource->showRelations() as $relation) { + $entries[] = "relationLabel($relation)}\" :value=\"{$variable}->{$relation->name}->pluck('{$this->relationNameField($relation)}')->implode(', ')\" />"; + } + + return Stub::make('views/blade/show.blade.stub')->with([ + ...$this->common(), + 'breadcrumbs' => $this->breadcrumbs(), + 'actions' => $this->showActions(), + 'trashedAlert' => $resource->hasSoftDeletes() + ? "@if({$variable}->trashed())\n \n {{ __('This {$this->escape($resource->singularLower())} is deleted.') }}\n \n@endif" + : '', + 'entries' => implode("\n", $entries), + 'media' => $this->showMedia(), + ])->render(); + } + + protected function showActions(): string + { + $resource = $this->resource; + $variable = '$' . $resource->variable(); + $label = $this->escape($resource->singularLower()); + + $edit = "@can('update', {$variable})\n" + . " route('edit', $variable)}\" start-icon=\"pencil\">\n" + . " {{ __('Edit') }}\n" + . " \n" + . '@endcan'; + + $delete = $this->buttonForm('delete', 'DELETE', $resource->route('destroy', $variable), 'danger', 'trash-2', 'Delete', "Delete this {$label}?"); + + if (! $resource->hasSoftDeletes()) { + return "{$edit}\n\n{$delete}"; + } + + $restore = $this->buttonForm('restore', 'PATCH', $resource->route('restore', $variable), 'success', '', 'Restore'); + $force = $this->buttonForm('forceDelete', 'DELETE', $resource->route('force-delete', $variable), 'danger', 'trash-2', 'Delete permanently', "Permanently delete this {$label}? This cannot be undone."); + + return "@if({$variable}->trashed())\n" + . Stub::indent("{$restore}\n\n{$force}") . "\n" + . "@else\n" + . Stub::indent("{$edit}\n\n{$delete}") . "\n" + . '@endif'; + } + + protected function buttonForm(string $ability, string $method, string $route, string $color, string $icon, string $label, ?string $confirm = null): string + { + $variable = '$' . $this->resource->variable(); + $icon = $icon !== '' ? " start-icon=\"{$icon}\"" : ''; + $onsubmit = $confirm ? ' onsubmit="return confirm(@js(__(\'' . $this->escape($confirm) . '\')))"' : ''; + + return "@can('{$ability}', {$variable})\n" + . "
\n" + . " @csrf\n" + . " @method('{$method}')\n\n" + . " \n" + . " {{ __('{$this->escape($label)}') }}\n" + . " \n" + . "
\n" + . '@endcan'; + } + + protected function showMedia(): string + { + $resource = $this->resource; + $variable = '$' . $resource->variable(); + + $cards = []; + + foreach ($resource->media as $media) { + $label = $this->escape($media->label()); + + if ($media->type === 'image' && ! $media->multiple) { + $content = "name}\" />"; + } elseif ($media->type === 'image') { + $content = "
\n" + . " @foreach({$variable}->getAttachments('{$media->name}') as \$attachment)\n" + . " \n" + . " @endforeach\n" + . '
'; + } else { + $content = "'; + } + + $cards[] = "\n" . Stub::indent($content) . "\n"; + } + + return $cards ? "\n" . implode("\n\n", $cards) : ''; + } + + /** + * A link back to the list (and the parent of a nested resource). + */ + protected function breadcrumbs(bool $to_index = true): string + { + $resource = $this->resource; + $links = []; + + if ($resource->isNested()) { + $parent = '$' . $resource->parentVariable(); + $links[] = "{{ {$parent}->{$resource->parent_name_field} }}"; + } + + if ($to_index) { + $links[] = "route('index')} }}\" class=\"hover:underline\">{{ __('{$this->escape($resource->pluralLabel())}') }}"; + } + + if (! $links) { + return ''; + } + + return "'; + } + + protected function relationLabel(BelongsToMany $relation): string + { + return "__('" . $this->escape(Str::ucfirst(str_replace('_', ' ', Str::snake($relation->name)))) . "')"; + } + + protected function relationNameField(BelongsToMany $relation): string + { + return app(ResourceResolver::class)->nameFieldOf($relation->related_table); + } + + protected function escape(string $value): string + { + return str_replace("'", "\\'", $value); + } +} diff --git a/src/Generators/BulkActionsGenerator.php b/src/Generators/BulkActionsGenerator.php new file mode 100644 index 0000000..44d6d35 --- /dev/null +++ b/src/Generators/BulkActionsGenerator.php @@ -0,0 +1,86 @@ +resource; + + return [new GeneratedFile($resource->classPath($resource->bulkActionsClass()), $this->renderBulkActions(), description: 'bulk actions')]; + } + + public function renderBulkActions(): string + { + $resource = $this->resource; + $namespace = $resource->namespace('bulk_actions'); + $imports = new Imports(['Javaabu\\BulkActions\\BulkActions'], $namespace); + + $model = $imports->add($resource->modelClass()); + $filters = $imports->add($resource->filtersClass()); + + $set_up = ["\$this->filters({$filters}::class);"]; + + $methods = ''; + + if ($resource->isNested()) { + $parent = $imports->add($resource->parentModelClass()); + $variable = $resource->parentVariable(); + $method = Str::camel($variable); + $relation = $resource->parent->getRelationName(); + $route = $resource->routeName('bulk'); + $key = $resource->parentKey(); + + $set_up[] = ''; + $set_up[] = "// only the {$resource->pluralLower()} of the {$resource->parentVariable()} in the URL"; + $set_up[] = "\$this->query(fn () => {$model}::query()->whereBelongsTo(\$this->{$method}(), '{$relation}'));"; + $set_up[] = "\$this->url(fn () => route('{$route}', \$this->{$method}()));"; + + $methods = <<parentVariable()} in the URL. + */ + protected function {$method}(): {$parent} + { + \${$variable} = request()->route('{$variable}'); + + if (\${$variable} instanceof {$parent}) { + return \${$variable}; + } + + return {$parent}::query()->where('{$key}', \${$variable})->firstOrFail(); + } + PHP; + } + + $delete = $imports->add('Javaabu\\BulkActions\\Actions\\DeleteAction'); + $actions = ["{$delete}::make()->can('{$resource->permission('delete')}')"]; + + if ($resource->hasSoftDeletes()) { + $restore = $imports->add('Javaabu\\BulkActions\\Actions\\RestoreAction'); + $force = $imports->add('Javaabu\\BulkActions\\Actions\\ForceDeleteAction'); + + $actions[] = "{$restore}::make()->can('{$resource->permission('delete')}')"; + $actions[] = "{$force}::make()->can('{$resource->permission('force_delete')}')"; + } + + return Stub::make('bulk-actions/BulkActions.stub')->with([ + 'namespace' => $namespace, + 'class' => class_basename($resource->bulkActionsClass()), + 'model' => $model, + 'setUp' => implode("\n", $set_up), + 'actions' => implode("\n", array_map(fn (string $action) => "{$action},", $actions)), + 'methods' => $methods, + 'imports' => $imports->render(), + ])->render(); + } +} diff --git a/src/Generators/Concerns/GeneratesAuthController.php b/src/Generators/Concerns/GeneratesAuthController.php deleted file mode 100644 index 2b192f1..0000000 --- a/src/Generators/Concerns/GeneratesAuthController.php +++ /dev/null @@ -1,27 +0,0 @@ -getNamespace() . '/Auth'; - } - - protected function renderAuthControllerFromStub(string $stub): string - { - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - - return $renderer->replaceNames($this->getAuthName(), $template, 'AuthName'); - } -} diff --git a/src/Generators/Concerns/GeneratesController.php b/src/Generators/Concerns/GeneratesController.php deleted file mode 100644 index 1a93357..0000000 --- a/src/Generators/Concerns/GeneratesController.php +++ /dev/null @@ -1,254 +0,0 @@ -getTable()) . 'Controller'; - } - - public function getControllerStub(): string - { - return 'generators::Controllers/Model' . ($this->hasSoftDeletes() ? 'SoftDeletes' : '' ) . 'Controller.stub'; - } - - protected function getControllerFillStoreIndentation(): int - { - return 2; - } - - protected function getControllerFillUpdateIndentation(): int - { - return 2; - } - - protected function getAdditionalControllerEagerLoads(): array - { - return []; - } - - protected function getAdditionalControllerOrderbys(): array - { - return []; - } - - /** - * Render the controller - */ - public function renderController(): string - { - $stub = $this->getControllerStub(); - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - - $orderbys = ''; - $booleans = []; - $foreign_keys = []; - $eager_loads = $this->getAdditionalControllerEagerLoads(); - - $order_columns = array_merge([ - $this->getKeyName(), - ], $this->getAdditionalControllerOrderbys()); - - if ($this->hasTimestamps()) { - $order_columns[] = 'created_at'; - $order_columns[] = 'updated_at'; - } - - foreach ($order_columns as $column) { - $orderbys .= $this->renderControllerOrderBy($column); - } - - /** - * @var string $column - * @var Field $field - */ - foreach ($this->getFields() as $column => $field) { - if ($field->isSortable()) { - $orderbys .= $this->renderControllerOrderBy($column); - } - - if ($field instanceof BooleanField) { - $booleans[] = $this->renderControllerBoolean($column, $field); - } elseif ($field instanceof ForeignKeyField) { - $foreign_keys[] = $this->renderControllerForeignKey($column, $field); - $eager_loads[] = "'" . $field->getRelationName() . "'"; - } - } - - $default_indent = 2; - $store_indent = $this->getControllerFillStoreIndentation(); - $update_indent = $this->getControllerFillUpdateIndentation(); - - $template = $renderer->appendMultipleContent([ - [ - 'search' => "static::\$orderbys = [\n", - 'keep_search' => true, - 'content' => $orderbys, - ], - [ - 'search' => '{{keyName}}', - 'keep_search' => false, - 'content' => $this->getKeyName(), - ], - [ - 'search' => '{{requestFillable}}', - 'keep_search' => false, - 'content' => $this->hasAnyFillable() ? '$request->validated()' : '', - ], - [ - 'search' => $renderer->addIndentation("// eager loads\n", 2), - 'keep_search' => false, - 'content' => $eager_loads ? $this->renderControllerEagerLoads($eager_loads) . "\n" : "\n", - ], - [ - 'search' => $renderer->addIndentation("// fill update\n", $update_indent), - 'keep_search' => false, - 'content' => $this->hasAnyFillable() ? $renderer->addIndentation($this->renderControllerUpdateFill(), $update_indent - $default_indent) : "\n", - ], - [ - 'search' => $renderer->addIndentation("// booleans\n", $store_indent), - 'keep_search' => false, - 'content' => $booleans ? "\n" . $renderer->addIndentation(implode("\n", $booleans), $store_indent - $default_indent) : '', - ], - [ - 'search' => $renderer->addIndentation("// foreign keys\n", $store_indent), - 'keep_search' => false, - 'content' => $foreign_keys ? "\n" . $renderer->addIndentation(implode("\n", $foreign_keys), $store_indent - $default_indent) : '', - ], - [ - 'search' => $renderer->addIndentation("// booleans update\n", $update_indent), - 'keep_search' => false, - 'content' => $booleans ? "\n" . $renderer->addIndentation(implode("\n", $booleans), $update_indent - $default_indent) : '', - ], - [ - 'search' => $renderer->addIndentation("// foreign keys update\n", $update_indent), - 'keep_search' => false, - 'content' => $foreign_keys ? "\n" . $renderer->addIndentation(implode("\n", $foreign_keys), $update_indent - $default_indent) : '', - ], - [ - 'search' => '{{appendSpaces}}', - 'keep_search' => false, - 'content' => Str::repeat(' ', (Str::length(StringCaser::pluralSnake($this->getTable())) * 2) + 2), - ], - ], $template); - - return $template; - } - - /** - * Render fill update - */ - public function renderControllerUpdateFill(): string - { - $stub = 'generators::Controllers/_controllerUpdateFill.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } - - /** - * Render eager loads - */ - public function renderControllerEagerLoads(array $relations): string - { - if (! $relations) { - return ''; - } - - $stub = 'generators::Controllers/_controllerEagerLoads.stub'; - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - - $template = $renderer->appendMultipleContent([ - [ - 'search' => '->with(', - 'keep_search' => true, - 'content' => implode(', ', $relations), - ], - ], $template); - - return $template; - } - - /** - * Render a boolean - */ - public function renderControllerBoolean(string $column, BooleanField $field): string - { - $stub = 'generators::Controllers/_controllerBoolean.stub'; - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - - $template = $renderer->appendMultipleContent([ - [ - 'search' => '{{inputName}}', - 'keep_search' => false, - 'content' => $field->getInputName(), - ], - [ - 'search' => '{{column}}', - 'keep_search' => false, - 'content' => $column, - ], - ], $template); - - return $template; - } - - /** - * Render a foreign key - */ - public function renderControllerForeignKey(string $column, ForeignKeyField $field): string - { - $stub = 'generators::Controllers/_controllerForeignKey.stub'; - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $field->getRelatedTable()); - $template = $renderer->replaceNames($this->getTable(), $template, 'Model'); - - $template = $renderer->appendMultipleContent([ - [ - 'search' => '{{attribute}}', - 'keep_search' => false, - 'content' => $field->getInputName(), - ], - [ - 'search' => '{{relationName}}', - 'keep_search' => false, - 'content' => $field->getRelationName(), - ], - ], $template); - - return $template; - } - - /** - * Render a foreign key - */ - public function renderControllerOrderBy(string $column): string - { - $stub = 'generators::Controllers/_orderBy.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $column); - } -} diff --git a/src/Generators/Concerns/GeneratesExport.php b/src/Generators/Concerns/GeneratesExport.php deleted file mode 100644 index 0f20354..0000000 --- a/src/Generators/Concerns/GeneratesExport.php +++ /dev/null @@ -1,78 +0,0 @@ -getExportStub(); - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - - $relations = []; - - /** - * @var string $column - * @var Field $field - */ - foreach ($this->getFields() as $column => $field) { - if ($field instanceof ForeignKeyField) { - $relations[] = "'" . $field->getRelationName() . "'"; - } - } - - $template = $renderer->appendMultipleContent([ - [ - 'search' => $renderer->addIndentation("// relations\n", 1), - 'keep_search' => false, - 'content' => $relations ? $this->renderExportRelations($relations) : '', - ], - ], $template); - - return $template; - } - - /** - * Render relations - */ - public function renderExportRelations(array $relations): string - { - if (! $relations) { - return ''; - } - - $stub = 'generators::Exports/_exportRelations.stub'; - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - - foreach ($relations as $i => $relation) { - $relations[$i] = $renderer->addIndentation($relation . ',', 3); - } - - $template = $renderer->appendMultipleContent([ - [ - 'search' => $renderer->addIndentation('// relations values', 3), - 'keep_search' => false, - 'content' => implode("\n", $relations), - ], - ], $template); - - return $template; - } -} diff --git a/src/Generators/Concerns/GeneratesFactory.php b/src/Generators/Concerns/GeneratesFactory.php deleted file mode 100644 index 8a57823..0000000 --- a/src/Generators/Concerns/GeneratesFactory.php +++ /dev/null @@ -1,244 +0,0 @@ -factory_stub : ''; - } - - /** - * Determine fake method attribute - */ - public function getFakerMethodFromColumnName(string $column): ?string - { - // don't want to use the name() method - if ($column == 'name') { - return null; - } - - $method = Str::camel($column); - - try { - if (method_exists($this->faker, $method) || $this->faker->getFormatter($method)) { - return $method; - } - } catch (\InvalidArgumentException $e) {} - - return null; - } - - /** - * Determine fake method attribute - */ - public function getFakerStatement(string $column, bool $use_faker_method = true, bool $required = false): ?string - { - $field = $this->getField($column); - - if (! $field) { - return null; - } - - $statement = 'fake()'; - - if ($field->isNullable() && (! $required)) { - $statement .= '->optional()'; - } - - if ($field->isUnique()) { - $statement .= '->unique()'; - - if ($field->isNullable() && (! $required)) { - $statement .= '?'; - } - } - - $faker_method = null; - - if ($use_faker_method) { - $faker_method = $this->getFakerMethodFromColumnName($column); - } - - if ($faker_method) { - $statement .= '->' . $faker_method . '()'; - } else { - $statement .= '->' . $field->generateFactoryStatement(); - } - - return $statement; - } - - /** - * Render the factory - */ - public function renderFactory(): string - { - $stub = $this->getFactoryStub(); - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - - $definitions = ''; - $foreign_keys = []; - $required_foreign_keys = []; - $use_statements = []; - - /** - * @var string $column - * @var Field $field - */ - foreach ($this->getFields() as $column => $field) { - if ($field instanceof ForeignKeyField) { - $foreign_keys[] = $this->renderForeignKey($column, $field); - - if ($field->isRequired()) { - $required_foreign_keys[$column] = $field; - } - - } else { - $statement = "'$column' => {$this->getFakerStatement($column)},\n"; - - if (Str::contains($statement, 'Str::')) { - $str_import = 'use Illuminate\\Support\\Str;'; - if (! in_array($str_import, $use_statements)) { - $use_statements[] = $str_import; - } - } - - if ($definitions) { - $definitions .= $renderer->addIndentation($statement, 3); - } else { - $definitions = $statement; - } - } - } - - $foreign_keys = implode("\n", $foreign_keys); - - $foreign_key_search = "// foreign keys\n"; - $required_relations = $this->renderRequiredRelations($required_foreign_keys); - - $template = $renderer->appendMultipleContent([ - [ - 'search' => "use Illuminate\\Database\\Eloquent\\Factories\\Factory;\n", - 'keep_search' => true, - 'content' => $use_statements ? implode("\n", $use_statements) . "\n" : '', - ], - [ - 'search' => "// definition\n", - 'keep_search' => false, - 'content' => $definitions, - ], - [ - 'search' => "// foreign keys\n", - 'keep_search' => true, - 'content' => $required_relations ? "\n" . $required_relations : '', - ], - [ - 'search' => $foreign_keys ? $foreign_key_search : $renderer->addIndentation($foreign_key_search, 1), - 'keep_search' => false, - 'content' => Str::replaceFirst(' ', '', $foreign_keys), - ] - ], $template); - - return $template; - } - - public function getFactoryName(): string - { - return StringCaser::singularStudly($this->getTable()) . 'Factory'; - } - - /** - * Render a foreign key - */ - public function renderForeignKey(string $column, ForeignKeyField $field): string - { - $stub = 'generators::factories/_factoryForeignKey.stub'; - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $field->getRelatedTable()); - $statement = "'$column' => {$this->getFakerStatement($column, use_faker_method: false, required: true)},\n"; - - return $renderer->appendMultipleContent([ - [ - 'search' => "// statement\n", - 'keep_search' => false, - 'content' => $statement, - ], - [ - 'search' => "{{factoryName}}", - 'keep_search' => false, - 'content' => $this->getFactoryName(), - ], - [ - 'search' => "{{relationName}}", - 'keep_search' => false, - 'content' => Str::ucfirst($field->getRelationName()), - ] - ], $template); - } - - /** - * Render a foreign key - */ - public function renderRequiredRelations(array $required_relations): string - { - if (! $required_relations) { - return ''; - } - - $stub = 'generators::factories/_factoryRequiredRelations.stub'; - $renderer = $this->getRenderer(); - - $code = ''; - - $i = 0; - $count = count($required_relations); - - /* @var ForeignKeyField $field */ - foreach ($required_relations as $column => $field) { - $is_first = $i == 0; - $is_last = $i == ($count - 1); - - $new_line = ! $is_last ? "\n" : ''; - $statement = '->with' . StringCaser::singularStudly($field->getRelatedTable()) . "()$new_line"; - - $code .= ! $is_first ? $renderer->addIndentation($statement, 2, match_length: 'return $this') : $statement; - - $i++; - } - - - - $template = $renderer->loadStub($stub); - - return $renderer->appendMultipleContent([ - [ - 'search' => "\$this", - 'keep_search' => true, - 'content' => $code, - ], - [ - 'search' => "{{factoryName}}", - 'keep_search' => false, - 'content' => $this->getFactoryName(), - ] - ], $template); - } -} diff --git a/src/Generators/Concerns/GeneratesModel.php b/src/Generators/Concerns/GeneratesModel.php deleted file mode 100644 index d76c00e..0000000 --- a/src/Generators/Concerns/GeneratesModel.php +++ /dev/null @@ -1,305 +0,0 @@ -model_casts_stub : ''; - } - - /** - * get the model stub - */ - protected function getModelStub(): string - { - return property_exists($this, 'model_stub') ? $this->model_stub : ''; - } - - /** - * Whether soft deletes should be rendered - */ - public function shouldRenderModelSoftDeletes(): bool - { - return true; - } - - /** - * Whether searchable should be rendered - */ - public function shouldRenderModelSearchable(): bool - { - return true; - } - - /** - * Whether admin link name - */ - public function shouldRenderModelAdminLinkName(): bool - { - return true; - } - - /** - * Get additional model casts - */ - public function renderAdditionalModelCasts(): string - { - return ''; - } - - /** - * Get additional model fillable - */ - public function renderAdditionalModelFillable(): string - { - return ''; - } - - /** - * Get additional model use statements - */ - public function getAdditionalModelUseStatements(): array - { - return []; - } - - public function renderModelCast(string $column, string $cast): string - { - return $this->getRenderer()->addIndentation("'$column' => $cast,\n", 3); - } - - public function renderModelCastsStub(string $casts): string - { - $stub = $this->getModelCastsStub(); - - $renderer = $this->getRenderer(); - - $template = $renderer->loadStub($stub); - - return $renderer->appendMultipleContent([ - [ - 'search' => "// casts\n", - 'keep_search' => false, - 'content' => $casts, - ], - ], $template); - } - - public function renderModelFillable(string $column): string - { - return $this->getRenderer()->addIndentation("'$column',\n", 2); - } - - /** - * Render the model - */ - public function renderModel(): string - { - $stub = $this->getModelStub(); - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - - $render_soft_deletes = $this->shouldRenderModelSoftDeletes(); - $render_searchable = $this->shouldRenderModelSearchable(); - - $use_statements = $this->getAdditionalModelUseStatements(); - $traits = ''; - $fillable = $this->renderAdditionalModelFillable(); - $casts = $this->renderAdditionalModelCasts(); - $searchable = ''; - $foreign_keys = []; - $date_mutators = []; - $admin_link_name = ''; - - $name_field = $this->getNameField(); - - if ($this->shouldRenderModelAdminLinkName() && $name_field != 'name') { - $admin_link_name = $this->renderModelAdminLinkName($name_field); - } - - if ($render_soft_deletes && $this->hasSoftDeletes()) { - $use_statements[] = 'use Illuminate\Database\Eloquent\SoftDeletes;'; - $traits .= $renderer->addIndentation("use SoftDeletes;\n", 1); - } - - /** - * @var string $column - * @var Field $field - */ - foreach ($this->getFields() as $column => $field) { - if ($field instanceof DateTypeField) { - $carbon_import = 'use Carbon\\Carbon;'; - if (! in_array($carbon_import, $use_statements)) { - $use_statements[] = $carbon_import; - } - - $date_mutators[] = $this->renderModelDateMutator($column, $field); - } - - if ($field instanceof ForeignKeyField) { - $belongs_to_import = 'use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;'; - if (! in_array($belongs_to_import, $use_statements)) { - $use_statements[] = $belongs_to_import; - } - - $foreign_keys[] = $this->renderModelForeignKey($column, $field); - } - - if ($field instanceof EnumField && $field->hasEnumClass()) { - $enum_import = 'use ' . $field->getEnumClass() . ';'; - if (! in_array($enum_import, $use_statements)) { - $use_statements[] = $enum_import; - } - } - - if ($field->isFillable()) { - $fillable .= $this->renderModelFillable($column); - } - - if ($cast = $field->generateCast()) { - - if ($field->shouldQuoteCast()) { - $cast = "'$cast'"; - } - - $casts .= $this->renderModelCast($column, $cast); - } - - if ($render_searchable && $field->isSearchable()) { - $searchable .= $renderer->addIndentation("'$column',\n", 2); - } - } - - $template = $renderer->appendMultipleContent([ - [ - 'search' => "// use statements\n", - 'keep_search' => false, - 'content' => $use_statements ? implode("\n", $use_statements) . "\n" : '', - ], - [ - 'search' => "use HasFactory;\n", - 'keep_search' => true, - 'content' => $traits, - ], - [ - 'search' => "protected \$fillable = [\n", - 'keep_search' => true, - 'content' => $fillable, - ], - [ - 'search' => $renderer->addIndentation("// casts\n", 1), - 'keep_search' => false, - 'content' => $casts ? "\n" . $this->renderModelCastsStub($casts) . "\n" : "\n", - ], - [ - 'search' => "protected \$searchable = [\n", - 'keep_search' => true, - 'content' => $searchable, - ], - [ - 'search' => $renderer->addIndentation("// admin link name\n", 1), - 'keep_search' => false, - 'content' => $admin_link_name ? "\n" . $admin_link_name : '', - ], - [ - 'search' => $renderer->addIndentation("// date mutators\n", 1), - 'keep_search' => false, - 'content' => $date_mutators ? implode("\n", $date_mutators) . "\n" : '', - ], - [ - 'search' => $renderer->addIndentation("// foreign keys\n", 1), - 'keep_search' => false, - 'content' => $foreign_keys ? "\n" . implode("\n", $foreign_keys) : '', - ], - ], $template); - - return $template; - } - - /** - * Render the admin link name - */ - public function renderModelAdminLinkName(string $name_field): string - { - $stub = 'generators::Models/_adminLinkName.stub'; - - $renderer = $this->getRenderer(); - - $template = $renderer->loadStub($stub); - - return $renderer->appendMultipleContent([ - [ - 'search' => "{{attribute}}", - 'keep_search' => false, - 'content' => $name_field, - ], - ], $template); - } - - /** - * Render a foreign key - */ - public function renderModelForeignKey(string $column, ForeignKeyField $field): string - { - $stub = 'generators::Models/_modelForeignKey.stub'; - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $field->getRelatedTable()); - $template = $renderer->replaceNames($this->getTable(), $template, 'Model'); - - $statement = $field->generateRelationStatement(); - - return $renderer->appendMultipleContent([ - [ - 'search' => "{{relationStatement}}", - 'keep_search' => false, - 'content' => $statement, - ], - [ - 'search' => "{{relationName}}", - 'keep_search' => false, - 'content' => $field->getRelationName(), - ], - ], $template); - } - - /** - * Render a date mutator - */ - public function renderModelDateMutator(string $column, DateTypeField $field): string - { - $stub_name = $field->isNullable() ? '_nullableDateMutator' : '_dateMutator'; - $stub = 'generators::Models/' . $stub_name . '.stub'; - - $renderer = $this->getRenderer(); - - $template = $renderer->loadStub($stub); - - return $renderer->appendMultipleContent([ - [ - 'search' => "{{attributeStudly}}", - 'keep_search' => false, - 'content' => StringCaser::studly($column), - ], - [ - 'search' => "{{attribute}}", - 'keep_search' => false, - 'content' => $column, - ], - ], $template); - } -} diff --git a/src/Generators/Concerns/GeneratesPermissions.php b/src/Generators/Concerns/GeneratesPermissions.php deleted file mode 100644 index 35ff95d..0000000 --- a/src/Generators/Concerns/GeneratesPermissions.php +++ /dev/null @@ -1,47 +0,0 @@ -permissions_stub : ''; - } - - protected function getAdditionalPermissionsStub(): string - { - return property_exists($this, 'additional_permissions_stub') ? $this->additional_permissions_stub : ''; - } - - protected function renderAdditionalPermissions(): string - { - if ($stub = $this->getAdditionalPermissionsStub()) { - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } - - return ''; - } - - public function renderPermissions(): string - { - $stub = $this->getPermissionsStub(); - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - - $additional_permissions = $this->renderAdditionalPermissions(); - - return $renderer->appendMultipleContent([ - [ - 'search' => "// additional permissions\n", - 'keep_search' => false, - 'content' => $additional_permissions, - ], - ], $template); - } -} diff --git a/src/Generators/Concerns/GeneratesPolicy.php b/src/Generators/Concerns/GeneratesPolicy.php deleted file mode 100644 index d34f6c9..0000000 --- a/src/Generators/Concerns/GeneratesPolicy.php +++ /dev/null @@ -1,47 +0,0 @@ -hasSoftDeletes() ? 'SoftDeletes' : '') . 'Policy.stub'; - } - - protected function getAdditionalPolicyMethodsStub(): string - { - return property_exists($this, 'additional_policy_methods_stub') ? $this->additional_policy_methods_stub : ''; - } - - protected function renderAdditionalPolicyMethods(): string - { - if ($stub = $this->getAdditionalPolicyMethodsStub()) { - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } - - return ''; - } - - /** - * Render the policy - */ - public function renderPolicy(): string - { - $stub = $this->getPolicyStub(); - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - - return $renderer->appendMultipleContent([ - [ - 'search' => $renderer->addIndentation("// additional policy methods\n", 1), - 'keep_search' => false, - 'content' => $this->renderAdditionalPolicyMethods(), - ], - ], $template); - } -} diff --git a/src/Generators/Concerns/GeneratesRequest.php b/src/Generators/Concerns/GeneratesRequest.php deleted file mode 100644 index b0458ff..0000000 --- a/src/Generators/Concerns/GeneratesRequest.php +++ /dev/null @@ -1,231 +0,0 @@ -request_stub : ''; - } - - /** - * Get the request rules body stub - */ - protected function getRequestRulesBodyStub(): string - { - return 'generators::Requests/_rulesBody.stub'; - } - - /** - * get the validation rules - */ - public function getRequestValidationRules(string $column, bool $add_required = false, bool $add_unique = false): array - { - $field = $this->getField($column); - - if (! $field) { - return []; - } - - $rules = []; - - if ($add_required && $field->isRequired()) { - $rules[] = 'required'; - } - - if ($field->isNullable()) { - $rules[] = 'nullable'; - } - - if ($add_unique && $field->isUnique()) { - $rules[] = $this->renderRequestColumnUniqueRule($column); - } - - return array_merge($rules, $field->generateValidationRules()); - } - - public function renderRequestRulesBody(): string - { - $stub = $this->getRequestStub(); - - $renderer = $this->getRenderer(); - - $template = $renderer->loadStub($stub); - - $body_stub = $renderer->loadStub($this->getRequestRulesBodyStub()); - - return $renderer->appendMultipleContent([ - [ - 'search' => $renderer->addIndentation("// rules body\n", 2), - 'keep_search' => false, - 'content' => $body_stub, - ], - ], $template); - } - - /** - * Whether to use inline unique rules - */ - protected function useInlineUniqueRules(): bool - { - return false; - } - - /** - * Whether to use inline required rules - */ - protected function useInlineRequiredRules(): bool - { - return false; - } - - /** - * Whether to render rules for only required columns - */ - protected function renderOnlyRequiredColumnRules(): bool - { - return false; - } - - public function renderRequestColumnUniqueRule(string $column): string - { - $input_name = $this->getField($column)->getInputName(); - - return "Rule::unique('{$this->getTable()}', '$input_name')"; - } - - - /** - * Render the request - */ - public function renderRequest(): string - { - $renderer = $this->getRenderer(); - - $template = $renderer->replaceNames($this->getTable(), $this->renderRequestRulesBody()); - $singular_snake = StringCaser::singularSnake($this->getTable()); - - $use_statements = []; - $rules = ''; - $unique_definitions = ''; - $unique_ignores = ''; - $unique_insertions = ''; - $requireds = ''; - - /** - * @var string $column - * @var Field $field - */ - foreach ($this->getFields() as $column => $field) { - if ($this->renderOnlyRequiredColumnRules() && (! $field->isRequired())) { - continue; - } - - $input_name = $field->getInputName(); - - if ($field->isRequired() && (! $this->useInlineRequiredRules())) { - $required_statement = '$rules[\'' . $input_name . "'][] = 'required';\n"; - - if ($requireds) { - $requireds .= $renderer->addIndentation($required_statement, 3); - } else { - $requireds .= $required_statement; - } - } - - if ($field->isUnique() && (! $this->useInlineUniqueRules())) { - $unique_rule = $this->renderRequestColumnUniqueRule($column); - $unique_definitions_statement = '$unique_' . $input_name . " = $unique_rule;\n"; - $unique_ignore_statement = '$unique_' . $input_name . "->ignore($" . $singular_snake . "->getKey());\n"; - $unique_insertion_statement = '$rules[\'' . $input_name . "'][] = " . '$unique_' . $input_name . ";\n"; - - if ($unique_definitions) { - $unique_definitions .= $renderer->addIndentation($unique_definitions_statement, 2); - $unique_ignores .= $renderer->addIndentation($unique_ignore_statement, 3); - $unique_insertions .= $renderer->addIndentation($unique_insertion_statement, 2); - } else { - $unique_definitions .= $unique_definitions_statement; - $unique_ignores .= $unique_ignore_statement; - $unique_insertions .= $unique_insertion_statement; - } - } - - $field_rules = collect($this->getRequestValidationRules($column, $this->useInlineRequiredRules(), $this->useInlineUniqueRules())) - ->transform(function ($value) { - return Str::startsWith($value, 'Rule::') ? $value : "'" . $value. "'"; - })->implode(', '); - - if (Str::contains($field_rules, 'Rule::') || $field->isUnique()) { - $rule_import = 'use Illuminate\\Validation\\Rule;'; - if (! in_array($rule_import, $use_statements)) { - $use_statements[] = $rule_import; - } - } - - if ($field instanceof EnumField && $field->hasEnumClass()) { - $enum_import = 'use ' . $field->getEnumClass() . ';'; - if (! in_array($enum_import, $use_statements)) { - $use_statements[] = $enum_import; - } - } - - $statement = "'$input_name' => [$field_rules],\n"; - - if ($rules) { - $rules .= $renderer->addIndentation($statement, 3); - } else { - $rules = $statement; - } - } - - $unique_ignores_search = "// unique ignores\n"; - $unique_definitions_search = "// unique definitions\n"; - $unique_insertions_search = "// unique rule insertions\n"; - $requireds_search = "// requireds\n"; - - - $template = $renderer->appendMultipleContent([ - [ - 'search' => "// use statements\n", - 'keep_search' => false, - 'content' => $use_statements ? implode("\n", $use_statements) . "\n" : '', - ], - [ - 'search' => "// rules\n", - 'keep_search' => false, - 'content' => $rules, - ], - [ - 'search' => $unique_definitions ? $unique_definitions_search : $renderer->addIndentation($unique_definitions_search, 2), - 'keep_search' => false, - 'content' => $unique_definitions ? $unique_definitions . "\n" : '', - ], - [ - 'search' => $unique_ignores ? $unique_ignores_search : $renderer->addIndentation($unique_ignores_search, 1), - 'keep_search' => false, - 'content' => $unique_ignores ? $unique_ignores : $renderer->addIndentation('//' . "\n", 1), - ], - [ - 'search' => $requireds ? $requireds_search : $renderer->addIndentation($requireds_search, 1), - 'keep_search' => false, - 'content' => $requireds, - ], - [ - 'search' => $unique_insertions ? $unique_insertions_search : $renderer->addIndentation($unique_insertions_search, 2), - 'keep_search' => false, - 'content' => $unique_insertions ? $unique_insertions . "\n" : '', - ], - ], $template); - - return $template; - } -} diff --git a/src/Generators/ControllerGenerator.php b/src/Generators/ControllerGenerator.php index 3bb8716..fd0ef8d 100644 --- a/src/Generators/ControllerGenerator.php +++ b/src/Generators/ControllerGenerator.php @@ -3,21 +3,420 @@ namespace Javaabu\Generators\Generators; use Illuminate\Support\Str; -use Javaabu\Generators\FieldTypes\BooleanField; -use Javaabu\Generators\FieldTypes\Field; -use Javaabu\Generators\FieldTypes\ForeignKeyField; -use Javaabu\Generators\Generators\Concerns\GeneratesController; -use Javaabu\GeneratorHelpers\StringCaser; +use Javaabu\Generators\Fields\ForeignKeyField; +use Javaabu\Generators\Support\Imports; +use Javaabu\Generators\Support\Stub; -class ControllerGenerator extends BaseGenerator +/** + * The admin resource controller: index with filters and bulk actions, the + * stencil form for create and edit, show, delete, and restore and force + * delete for soft-deleting models. Renders Blade views or Inertia pages. + */ +class ControllerGenerator extends Generator { - use GeneratesController; + protected Imports $imports; + + public function files(): array + { + $resource = $this->resource; + + return [new GeneratedFile($resource->classPath($resource->controllerClass()), $this->renderController(), description: 'controller')]; + } + + public function renderController(): string + { + $resource = $this->resource; + $namespace = $resource->namespace('controllers'); + + $this->imports = new Imports([ + 'App\\Http\\Controllers\\Controller', + 'Illuminate\\Http\\RedirectResponse', + 'Illuminate\\Support\\Facades\\Gate', + ], $namespace); + + $this->imports->add($resource->modelClass()); + + if ($resource->isNested()) { + $this->imports->add($resource->parentModelClass()); + } + + $methods = [ + $this->index(), + $this->create(), + $this->store(), + $this->show(), + $this->edit(), + $this->update(), + $this->destroy(), + ]; + + if ($resource->hasSoftDeletes()) { + $methods[] = $this->restore(); + $methods[] = $this->forceDelete(); + } + + return Stub::make('controller/Controller.stub')->with([ + 'namespace' => $namespace, + 'class' => class_basename($resource->controllerClass()), + 'methods' => implode("\n\n", $methods), + 'imports' => $this->imports->render(), + ])->render(); + } + + protected function index(): string + { + $resource = $this->resource; + $model = $this->model(); + $filters = $this->imports->add($resource->filtersClass()); + $bulk_actions = $this->imports->add($resource->bulkActionsClass()); + $this->imports->add('Illuminate\\Http\\Request'); + + $plural = $resource->pluralVariable(); + + $query = "{$model}::query()"; + + if ($resource->isNested()) { + $query .= "->whereBelongsTo(\${$resource->parentVariable()}, '{$resource->parent->getRelationName()}')"; + } + + if ($with = $this->eagerLoads($resource->indexFields(), $resource->indexRelations())) { + $query .= "->with({$with})"; + } + + $props = $this->parentProps(); + + if ($resource->isInertia()) { + $collection = $this->imports->add($resource->resourceClass()); + + $props[] = "'" . Str::camel($plural) . "' => {$collection}::collection(\${$plural}),"; + $props[] = "'filters' => \$filters,"; + $props[] = "'bulkActions' => {$bulk_actions}::make()->paginator(\${$plural}),"; + $props[] = "'createUrl' => Gate::allows('create', {$model}::class) ? {$resource->route('create')} : null,"; + } else { + $props[] = "'{$plural}' => \${$plural},"; + $props[] = "'filters' => \$filters,"; + $props[] = "'bulk_actions' => {$bulk_actions}::make()->paginator(\${$plural}),"; + } + + $body = <<apply({$query}) + ->paginate(\$request->integer('per_page', 20)) + ->withQueryString(); + + {$this->respond('index', 'Index', $props)} + PHP; + + return $this->method( + "List the {$resource->pluralLower()}.", + 'index', + ['Request $request', ...$this->parentParameter()], + $this->responseType(), + $body + ); + } + + protected function create(): string + { + $resource = $this->resource; + $model = $this->model(); + $form = $this->imports->add($resource->formClass()); + + $props = [ + ...$this->parentProps(), + "'form' => {$form}::make()->model({$model}::class)->action({$resource->route('store')}),", + ]; + + $body = <<respond('create', 'Create', $props)} + PHP; + + return $this->method("Show the form for a new {$resource->singularLower()}.", 'create', $this->parentParameter(), $this->responseType(), $body); + } + + protected function store(): string + { + $resource = $this->resource; + $model = $this->model(); + $form = $this->imports->add($resource->formClass()); + $variable = $resource->variable(); + + $record = $resource->isNested() + ? "(new {$model})->{$resource->parent->getRelationName()}()->associate(\${$resource->parentVariable()})" + : "{$model}::class"; + + $message = $this->message("{$resource->singularLabel()} created."); + + $body = <<model({$record})->save(); + + return redirect({$resource->route('show', "\${$variable}")}) + ->with('status', {$message}); + PHP; + + return $this->method("Save a new {$resource->singularLower()}.", 'store', $this->parentParameter(), 'RedirectResponse', $body); + } + + protected function show(): string + { + $resource = $this->resource; + $variable = $resource->variable(); + + $load = ''; + + if ($with = $this->eagerLoads($resource->showFields(), $resource->showRelations())) { + $load = "\n\n\${$variable}->load({$with});"; + } + + $props = $this->parentProps(); + + $props[] = $resource->isInertia() + ? "'" . Str::camel($variable) . "' => " . $this->imports->add($resource->resourceClass()) . "::make(\${$variable})->resolve()," + : "'{$variable}' => \${$variable},"; + + $body = <<respond('show', 'Show', $props)} + PHP; + + return $this->method("Show a {$resource->singularLower()}.", 'show', [...$this->parentParameter(), $this->recordParameter()], $this->responseType(), $body); + } + + protected function edit(): string + { + $resource = $this->resource; + $variable = $resource->variable(); + $form = $this->imports->add($resource->formClass()); + + $props = $this->parentProps(); + + if ($resource->isInertia()) { + $props[] = "'" . Str::camel($variable) . "' => " . $this->imports->add($resource->resourceClass()) . "::make(\${$variable})->resolve(),"; + } else { + $props[] = "'{$variable}' => \${$variable},"; + } + + $props[] = "'form' => {$form}::make()->model(\${$variable})->action({$resource->route('update', "\${$variable}")}),"; + + $body = <<respond('edit', 'Edit', $props)} + PHP; + + return $this->method("Show the form for editing a {$resource->singularLower()}.", 'edit', [...$this->parentParameter(), $this->recordParameter()], $this->responseType(), $body); + } + + protected function update(): string + { + $resource = $this->resource; + $variable = $resource->variable(); + $form = $this->imports->add($resource->formClass()); + + $message = $this->message("{$resource->singularLabel()} updated."); + + $body = <<model(\${$variable})->save(); + + return redirect({$resource->route('show', "\${$variable}")}) + ->with('status', {$message}); + PHP; + + return $this->method("Save the changes to a {$resource->singularLower()}.", 'update', [...$this->parentParameter(), $this->recordParameter()], 'RedirectResponse', $body); + } + + protected function destroy(): string + { + $resource = $this->resource; + $variable = $resource->variable(); + + $message = $this->message("{$resource->singularLabel()} deleted."); + + $body = <<delete(); + + return redirect({$resource->route('index')}) + ->with('status', {$message}); + PHP; + + return $this->method("Delete a {$resource->singularLower()}.", 'destroy', [...$this->parentParameter(), $this->recordParameter()], 'RedirectResponse', $body); + } + + protected function restore(): string + { + $resource = $this->resource; + $variable = $resource->variable(); + + $message = $this->message("{$resource->singularLabel()} restored."); + + $body = <<restore(); + + return redirect({$resource->route('show', "\${$variable}")}) + ->with('status', {$message}); + PHP; + + return $this->method("Restore a deleted {$resource->singularLower()}.", 'restore', [...$this->parentParameter(), $this->recordParameter()], 'RedirectResponse', $body); + } + + protected function forceDelete(): string + { + $resource = $this->resource; + $variable = $resource->variable(); + + $message = $this->message("{$resource->singularLabel()} permanently deleted."); + + $body = <<forceDelete(); + + return redirect({$resource->route('index')}) + ->with('status', {$message}); + PHP; + + return $this->method("Permanently delete a {$resource->singularLower()}.", 'forceDelete', [...$this->parentParameter(), $this->recordParameter()], 'RedirectResponse', $body); + } + + /* + |-------------------------------------------------------------------------- + | Helpers + |-------------------------------------------------------------------------- + */ /** - * Render the controller + * @param array $props */ - public function render(): string + protected function respond(string $view, string $page, array $props): string + { + $resource = $this->resource; + $props = implode("\n", array_map(fn (string $prop) => " {$prop}", $props)); + + if ($resource->isInertia()) { + $inertia = $this->imports->add('Inertia\\Inertia'); + + return "return {$inertia}::render('{$resource->pageName($page)}', [\n{$props}\n]);"; + } + + return "return view('{$resource->viewName($view)}', [\n{$props}\n]);"; + } + + protected function responseType(): string { - return $this->renderController(); + if ($this->resource->isInertia()) { + return $this->imports->add('Inertia\\Response'); + } + + return $this->imports->add('Illuminate\\Contracts\\View\\View'); + } + + protected function model(): string + { + return $this->imports->add($this->resource->modelClass()); + } + + /** + * @return array + */ + protected function parentParameter(): array + { + if (! $this->resource->isNested()) { + return []; + } + + $parent = $this->imports->add($this->resource->parentModelClass()); + + return ["{$parent} \${$this->resource->parentVariable()}"]; + } + + /** + * @return array + */ + protected function parentProps(): array + { + if (! $this->resource->isNested()) { + return []; + } + + $variable = $this->resource->parentVariable(); + $key = $this->resource->isInertia() ? Str::camel($variable) : $variable; + + return ["'{$key}' => \${$variable},"]; + } + + protected function recordParameter(): string + { + return "{$this->model()} \${$this->resource->variable()}"; + } + + /** + * The relations to eager load for these fields, as a PHP array. + * + * @param array $fields + * @param array $relations + */ + protected function eagerLoads(array $fields, array $relations): ?string + { + $loads = []; + + foreach ($fields as $column => $field) { + if (! $field instanceof ForeignKeyField) { + continue; + } + + if ($this->resource->parent && $column === $this->resource->parent->getName()) { + continue; + } + + $loads[] = $field->getRelationName(); + } + + foreach ($relations as $relation) { + $loads[] = $relation->name; + } + + if (! $loads) { + return null; + } + + return "['" . implode("', '", array_unique($loads)) . "']"; + } + + protected function message(string $message): string + { + return "__('" . str_replace("'", "\\'", $message) . "')"; + } + + /** + * @param array $parameters + */ + protected function method(string $comment, string $name, array $parameters, string $return, string $body): string + { + $parameters = implode(', ', $parameters); + $body = Stub::indent($body, 1); + + return <<resource; + + return [new GeneratedFile($resource->classPath($resource->exportClass()), $this->renderExport(), description: 'export')]; + } - public function render(): string + public function renderExport(): string { - return $this->renderExport(); + $resource = $this->resource; + $namespace = $resource->namespace('exports'); + $imports = new Imports(['Javaabu\\Exports\\ModelExport'], $namespace); + + $model = $imports->add($resource->modelClass()); + + return Stub::make('export/Export.stub')->with([ + 'namespace' => $namespace, + 'class' => class_basename($resource->exportClass()), + 'model' => $model, + 'imports' => $imports->render(), + ])->render(); } } diff --git a/src/Generators/FactoryGenerator.php b/src/Generators/FactoryGenerator.php index b264c30..93ca57e 100644 --- a/src/Generators/FactoryGenerator.php +++ b/src/Generators/FactoryGenerator.php @@ -2,31 +2,48 @@ namespace Javaabu\Generators\Generators; -use Faker\Generator; -use Illuminate\Support\Str; -use Javaabu\Generators\FieldTypes\Field; -use Javaabu\Generators\FieldTypes\ForeignKeyField; -use Javaabu\Generators\Generators\Concerns\GeneratesFactory; -use Javaabu\GeneratorHelpers\StringCaser; - -class FactoryGenerator extends BaseGenerator -{ - use GeneratesFactory; - - protected string $factory_stub = 'generators::factories/ModelFactory.stub'; +use Javaabu\Generators\Support\Imports; +use Javaabu\Generators\Support\Stub; - /** - * Constructor - */ - public function __construct(string $table, array $columns = []) +class FactoryGenerator extends Generator +{ + public function files(): array { - parent::__construct($table, $columns); + $resource = $this->resource; - $this->faker = app(Generator::class); + return [new GeneratedFile($resource->classPath($resource->factoryClass()), $this->renderFactory(), description: 'factory')]; } - public function render(): string + public function renderFactory(): string { - return $this->renderFactory(); + $resource = $this->resource; + $namespace = $resource->namespace('factories'); + $imports = new Imports(['Illuminate\\Database\\Eloquent\\Factories\\Factory'], $namespace); + + $model = $imports->add($resource->modelClass()); + + $definition = []; + + foreach ($resource->fields as $column => $field) { + if ($resource->isUserType() && $column === 'status') { + $definition[] = "'status' => " . $imports->add('Javaabu\\Auth\\Enums\\UserStatuses') . '::APPROVED,'; + + continue; + } + + $definition[] = "'{$column}' => {$field->getFactoryValue($imports)},"; + } + + if ($resource->isUserType() && $resource->table->hasColumn('email_verified_at')) { + $definition[] = "'email_verified_at' => now(),"; + } + + return Stub::make('factory/Factory.stub')->with([ + 'namespace' => $namespace, + 'class' => class_basename($resource->factoryClass()), + 'model' => $model, + 'definition' => implode("\n", $definition), + 'imports' => $imports->render(), + ])->render(); } } diff --git a/src/Generators/FiltersGenerator.php b/src/Generators/FiltersGenerator.php new file mode 100644 index 0000000..27af812 --- /dev/null +++ b/src/Generators/FiltersGenerator.php @@ -0,0 +1,148 @@ +resource; + + return [new GeneratedFile($resource->classPath($resource->filtersClass()), $this->renderFilters(), description: 'filters')]; + } + + public function renderFilters(): string + { + $resource = $this->resource; + $namespace = $resource->namespace('filters'); + $imports = new Imports(['Javaabu\\Filters\\Filters'], $namespace); + + $model = $imports->add($resource->modelClass()); + + return Stub::make('filters/Filters.stub')->with([ + 'namespace' => $namespace, + 'class' => class_basename($resource->filtersClass()), + 'model' => $model, + 'defaultSort' => $resource->hasTimestamps() ? 'created_at' : $resource->key_name, + 'filters' => implode("\n", array_map(fn (string $filter) => "{$filter},", $this->filters($imports))), + 'sorts' => implode("\n", array_map(fn (string $sort) => "{$sort},", $this->sorts($imports))), + 'imports' => $imports->render(), + ])->render(); + } + + /** + * @return array + */ + public function filters(Imports $imports): array + { + $resource = $this->resource; + $filters = []; + + if ($columns = $resource->searchableColumns()) { + $text = $imports->add('Javaabu\\Filters\\TextFilter'); + $list = implode(', ', array_map(fn (string $column) => "'{$column}'", $columns)); + + $filters[] = "{$text}::make('search')->label(__('Search'))->columns([{$list}])"; + } + + $index = array_keys($resource->indexFields()); + + /** @var Field $field */ + foreach ($resource->fields as $column => $field) { + if ($resource->parent && $column === $resource->parent->getName()) { + continue; + } + + $always = $field instanceof EnumField || $field instanceof ForeignKeyField || $field instanceof BooleanField; + + if (! $always && ! in_array($column, $index, true)) { + continue; + } + + if ($filter = $field->getFilter($imports)) { + $filters[] = $filter; + } + } + + foreach ($resource->belongs_to_many as $relation) { + $select = $imports->add('Javaabu\\Filters\\SelectFilter'); + $label = Str::ucfirst(str_replace('_', ' ', Str::snake($relation->name))); + $name_field = app(ResourceResolver::class)->nameFieldOf($relation->related_table); + + $filters[] = "{$select}::make('{$relation->name}')->label(__('{$label}'))->relationship('{$relation->name}', '{$name_field}')->multiple()"; + } + + if ($resource->hasTimestamps()) { + $range = $imports->add('Javaabu\\Filters\\DateRangeFilter'); + + $filters[] = "{$range}::make('created_at')->label(__('Created at'))"; + } + + if ($resource->hasSoftDeletes()) { + $trashed = $imports->add('Javaabu\\Filters\\TrashedFilter'); + + $filters[] = "{$trashed}::make()"; + } + + return $filters; + } + + /** + * @return array + */ + public function sorts(Imports $imports): array + { + $resource = $this->resource; + $sort = $imports->add('Javaabu\\Filters\\Sort'); + + $sorts = []; + + foreach ($resource->indexFields() as $column => $field) { + if ($field->isSortable()) { + $sorts[] = "{$sort}::make('{$column}')->label({$field->getLabelCall()})"; + } + } + + if ($resource->hasTimestamps()) { + $sorts[] = "{$sort}::make('created_at')->label(__('Created at'))"; + $sorts[] = "{$sort}::make('updated_at')->label(__('Updated at'))"; + } + + if (! $sorts) { + $sorts[] = "{$sort}::make('{$resource->key_name}')->label(__('ID'))"; + } + + return $sorts; + } + + /** + * Columns the index table can sort by. + * + * @return array + */ + public function sortableColumns(): array + { + $columns = []; + + foreach ($this->resource->indexFields() as $column => $field) { + if ($field->isSortable()) { + $columns[] = $column; + } + } + + return $columns; + } +} diff --git a/src/Generators/FormGenerator.php b/src/Generators/FormGenerator.php new file mode 100644 index 0000000..eb2953c --- /dev/null +++ b/src/Generators/FormGenerator.php @@ -0,0 +1,80 @@ +resource; + + return [new GeneratedFile($resource->classPath($resource->formClass()), $this->renderForm(), description: 'form')]; + } + + public function renderForm(): string + { + $resource = $this->resource; + $namespace = $resource->namespace('forms'); + $imports = new Imports(['Javaabu\\Stencil\\Form'], $namespace); + + $section = $imports->add('Javaabu\\Stencil\\Components\\Section'); + + $fields = []; + + foreach ($resource->formFields() as $column => $field) { + $extra = in_array($column, $resource->translatable, true) ? ['translatable()'] : []; + + $fields[] = $field->getStencilField($imports, $extra) . ','; + } + + foreach ($resource->formRelations() as $relation) { + $select = $imports->add('Javaabu\\Stencil\\Fields\\Select'); + $label = Str::ucfirst(str_replace('_', ' ', Str::snake($relation->name))); + $name_field = app(ResourceResolver::class)->nameFieldOf($relation->related_table); + + $fields[] = "{$select}::make('{$relation->name}')\n ->label(__('{$label}'))\n ->multiple()\n ->relationship('{$relation->name}', '{$name_field}')\n ->searchable(),"; + } + + $title = str_replace("'", "\\'", $resource->singularLabel()); + + $sections = []; + + $sections[] = "{$section}::make(__('{$title}'))\n ->columns(2)\n ->schema([\n" . $this->indentLines(implode("\n", $fields), 2) . "\n ]),"; + + if ($resource->hasMedia()) { + $picker = $imports->add('Javaabu\\Mediapicker\\Stencil\\MediaPickerField'); + + $media = []; + + foreach ($resource->media as $collection) { + $label = str_replace("'", "\\'", $collection->label()); + $multiple = $collection->multiple ? "\n ->multiple()" : ''; + + $media[] = "{$picker}::make('{$collection->name}')\n ->label(__('{$label}'))\n ->mediaType('{$collection->type}'){$multiple},"; + } + + $sections[] = "{$section}::make(__('Media'))\n ->schema([\n" . $this->indentLines(implode("\n", $media), 2) . "\n ]),"; + } + + return Stub::make('form/Form.stub')->with([ + 'namespace' => $namespace, + 'class' => class_basename($resource->formClass()), + 'schema' => implode("\n", $sections), + 'imports' => $imports->render(), + ])->render(); + } + + protected function indentLines(string $code, int $levels): string + { + return Stub::indent($code, $levels); + } +} diff --git a/src/Generators/GeneratedFile.php b/src/Generators/GeneratedFile.php new file mode 100644 index 0000000..8fef260 --- /dev/null +++ b/src/Generators/GeneratedFile.php @@ -0,0 +1,40 @@ +merge !== null; + } + + public function mergeInto(string $existing): string + { + return ($this->merge)($existing); + } + + public function relativePath(): string + { + $base = rtrim(base_path(), '/') . '/'; + + return str_starts_with($this->path, $base) ? substr($this->path, strlen($base)) : $this->path; + } +} diff --git a/src/Generators/Generator.php b/src/Generators/Generator.php new file mode 100644 index 0000000..d486f61 --- /dev/null +++ b/src/Generators/Generator.php @@ -0,0 +1,51 @@ + + */ + abstract public function files(): array; + + public function getResource(): ResourceDefinition + { + return $this->resource; + } + + /** + * Everything the generator writes, for printing. + */ + public function render(): string + { + $output = []; + + foreach ($this->files() as $file) { + $output[] = count($this->files()) > 1 ? "// {$file->relativePath()}\n{$file->contents}" : $file->contents; + } + + return implode("\n", $output); + } + + /** + * Join PHP blocks with a blank line between them. + * + * @param array $blocks + */ + protected function blocks(array $blocks): string + { + return implode("\n\n", array_filter($blocks, fn (?string $block) => $block !== null && $block !== '')); + } + + protected function namespaceOf(string $class): string + { + return substr($class, 0, (int) strrpos($class, '\\')); + } +} diff --git a/src/Generators/InertiaPagesGenerator.php b/src/Generators/InertiaPagesGenerator.php new file mode 100644 index 0000000..de0a1bd --- /dev/null +++ b/src/Generators/InertiaPagesGenerator.php @@ -0,0 +1,556 @@ +resource; + + return [ + new GeneratedFile($this->typesPath(), $this->renderTypes(), description: 'page types'), + new GeneratedFile($resource->pagePath('Index'), $this->renderIndex(), description: 'index page'), + new GeneratedFile($resource->pagePath('Create'), $this->renderCreate(), description: 'create page'), + new GeneratedFile($resource->pagePath('Edit'), $this->renderEdit(), description: 'edit page'), + new GeneratedFile($resource->pagePath('Show'), $this->renderShow(), description: 'show page'), + ]; + } + + public function typesPath(): string + { + return dirname($this->resource->pagePath('Index')) . '/types.ts'; + } + + /** Product */ + protected function type(): string + { + return $this->resource->model(); + } + + /** product */ + protected function variable(): string + { + return Str::camel($this->resource->variable()); + } + + /** products */ + protected function pluralVariable(): string + { + return Str::camel($this->resource->pluralVariable()); + } + + public function renderTypes(): string + { + $resource = $this->resource; + $lines = []; + + $lines[] = "{$resource->key_name}: number"; + + foreach ($resource->fields as $column => $field) { + $type = $field->getTypeScriptType(); + + $lines[] = "{$column}: {$type}" . ($field->isNullable() ? ' | null' : ''); + + if ($field instanceof ForeignKeyField) { + $key_type = $field->getRelatedKey() === 'id' ? 'number' : 'string'; + + $lines[] = "{$field->getDisplayName()}?: { {$field->getRelatedKey()}: {$key_type}; {$field->getRelatedNameField()}: string } | null"; + } + } + + foreach ($resource->belongs_to_many as $relation) { + $lines[] = "{$relation->name}?: { id: number; {$this->relationNameField($relation)}: string }[]"; + } + + foreach ($resource->media as $media) { + $lines[] = $media->multiple ? "{$media->name}: string[]" : "{$media->name}: string | null"; + } + + if ($resource->hasTimestamps()) { + $lines[] = 'created_at: string | null'; + $lines[] = 'updated_at: string | null'; + } + + if ($resource->hasSoftDeletes()) { + $lines[] = 'deleted_at: string | null'; + } + + $urls = ['show: string', 'edit: string', 'destroy: string']; + $can = ['view: boolean', 'update: boolean', 'delete: boolean']; + + if ($resource->hasSoftDeletes()) { + $urls[] = 'restore: string'; + $urls[] = 'force_delete: string'; + $can[] = 'restore: boolean'; + $can[] = 'forceDelete: boolean'; + } + + $lines[] = 'urls: { ' . implode('; ', $urls) . ' }'; + $lines[] = 'can: { ' . implode('; ', $can) . ' }'; + + $parent = ''; + + if ($resource->isNested()) { + $parent_type = $resource->parentModel(); + $key = $resource->parentKey(); + $key_type = $key === 'id' ? 'number' : 'string'; + + $parent = "\n\n/** The {$resource->parentVariable()} the {$resource->pluralLower()} belong to. */\nexport interface {$parent_type} {\n {$key}: {$key_type}\n {$resource->parent_name_field}: string\n}"; + } + + $body = implode("\n", array_map(fn (string $line) => " {$line}", $lines)); + + return "/** A {$resource->singularLower()}, as {$this->type()}Resource sends it. */\nexport interface {$this->type()} {\n{$body}\n}{$parent}\n"; + } + + public function renderIndex(): string + { + $resource = $this->resource; + $type = $this->type(); + $item = $this->variable(); + $items = $this->pluralVariable(); + $sortable = (new FiltersGenerator($resource))->sortableColumns(); + + $components = ['Pagination', 'TableCell', 'TableEmptyRow', 'TableHeading', 'TableRow', 'LinkButton']; + $headers = []; + $cells = []; + $uses_sortable = false; + + foreach ($resource->indexFields() as $column => $field) { + $label = $this->label($field->getLabel()); + + if (in_array($column, $sortable, true)) { + $uses_sortable = true; + $headers[] = ""; + } else { + $headers[] = ""; + } + + if ($column === $resource->nameField()) { + $cells[] = "\n" + . " {{$item}.can.view ? (\n" + . " \n" + . " {{$item}.{$column}}\n" + . " \n" + . " ) : (\n" + . " {$item}.{$column}\n" + . " )}\n" + . ''; + + continue; + } + + $cells[] = $field->getReactCell($item); + } + + foreach ($resource->indexRelations() as $relation) { + $label = $this->label($this->relationLabel($relation)); + + $headers[] = ""; + $cells[] = "name} ?? []).map((related) => related.{$this->relationNameField($relation)}).join(\", \")} />"; + } + + $filters_import = $uses_sortable ? 'FilterBar, SortableHeading, type FiltersData' : 'FilterBar, type FiltersData'; + $columns = count($headers) + 1; + + $parent_type = $resource->isNested() ? "\n {$this->parentProp()}: {$resource->parentModel()}" : ''; + $parent_param = $resource->isNested() ? "{$this->parentProp()}, " : ''; + $types_import = $resource->isNested() ? "{$type}, {$resource->parentModel()}" : $type; + + $heading = $this->heading($resource->pluralLabel(), 8); + + $headers = $this->indent(implode("\n", $headers), 12); + $cells = $this->indent(implode("\n", $cells), 14); + + $forms = implode(', ', $this->sortImports([...$components, 'type LaravelResourceCollection'])); + + return << & { data: {$type}[] } + filters: FiltersData + bulkActions: BulkActionsSchema + createUrl: string | null + } + + export default function Index({ {$parent_param}{$items}, filters, bulkActions, createUrl }: Props) { + return ( + <> + label($resource->pluralLabel())}} /> + +
+ {$heading} + + {createUrl && ( + + {{$this->label('New ' . $resource->singularLower())}} + + )} +
+ + + + router.reload()} + headers={ + <> + {$headers} + + } + pagination={{$items}.meta.last_page > 1 ? : null} + > + {{$items}.data.length > 0 ? ( + {$items}.data.map(({$item}) => ( + key_name}} model={{$item}} name="{$resource->pluralVariable()}"> + {$cells} + + )) + ) : ( + + {filters.active > 0 + ? {$this->label("No {$resource->pluralLower()} match these filters.")} + : {$this->label("No {$resource->pluralLower()} yet.")}} + + )} + + + ) + } + + TSX; + } + + public function renderCreate(): string + { + return $this->renderFormPage('Create', 'New ' . $this->resource->singularLower(), false); + } + + public function renderEdit(): string + { + return $this->renderFormPage('Edit', 'Edit ' . $this->resource->singularLower(), true); + } + + protected function renderFormPage(string $page, string $title, bool $editing): string + { + $resource = $this->resource; + $type = $this->type(); + $item = $this->variable(); + + $media = $resource->hasMedia(); + + $imports = ['import { Head } from "@inertiajs/react"']; + + if ($editing) { + $imports[0] = 'import { Head } from "@inertiajs/react"'; + $imports[] = 'import { LinkButton } from "@javaabu/forms"'; + } + + $imports[] = 'import { __ } from "@javaabu/i18n"'; + + if ($media) { + $imports[] = 'import { StencilMediaPicker } from "@javaabu/mediapicker"'; + } + + $imports[] = 'import { StencilForm, type StencilSchema } from "@javaabu/stencil"'; + + $types = []; + + if ($editing) { + $types[] = $type; + } + + if ($resource->isNested()) { + $types[] = $resource->parentModel(); + } + + if ($types) { + $imports[] = 'import type { ' . implode(', ', $types) . ' } from "./types"'; + } + + $props = []; + $params = []; + + if ($resource->isNested()) { + $props[] = "{$this->parentProp()}: {$resource->parentModel()}"; + $params[] = $this->parentProp(); + } + + if ($editing) { + $props[] = "{$item}: {$type}"; + $params[] = $item; + } + + $props[] = 'form: StencilSchema'; + $params[] = 'form'; + + $props = implode("\n", array_map(fn (string $prop) => " {$prop}", $props)); + $params = implode(', ', $params); + + $components = $media ? ' components={{ mediapicker: StencilMediaPicker }}' : ''; + + if ($editing) { + $heading = "
\n" + . $this->heading($title, 8) . "\n\n" + . " \n" + . " {{$this->label('View')}}\n" + . " \n" + . '
'; + } else { + $heading = "
\n" . $this->heading($title, 8) . "\n
"; + } + + $imports = implode("\n", $imports); + + return << + label($title)}} /> + + {$heading} + + + + ) + } + + TSX; + } + + public function renderShow(): string + { + $resource = $this->resource; + $type = $this->type(); + $item = $this->variable(); + + $components = ['Button', 'Infolist', 'LinkButton']; + $entries = []; + + foreach ($resource->showFields() as $field) { + $entries[] = $field->getReactEntry($item); + $components = [...$components, ...array_filter($field->getReactComponents(), fn (string $component) => $component !== 'TableCell')]; + } + + foreach ($resource->showRelations() as $relation) { + $components[] = 'TextEntry'; + $entries[] = "label($this->relationLabel($relation))}} value={({$item}.{$relation->name} ?? []).map((related) => related.{$this->relationNameField($relation)}).join(\", \")} />"; + } + + $media = []; + + foreach ($resource->media as $collection) { + $components[] = 'Card'; + $label = $this->label($collection->label()); + + if ($collection->multiple) { + $content = $collection->type === 'image' + ? "
\n {{$item}.{$collection->name}.map((url) => (\n \"\"\n ))}\n
" + : ""; + } else { + $content = $collection->type === 'image' + ? "{{$item}.{$collection->name} && name}} alt=\"\" className=\"rounded-lg\" />}" + : "{{$item}.{$collection->name} && (\n name}} className=\"hover:underline\">\n {{$item}.{$collection->name}.split(\"/\").pop()}\n \n)}"; + } + + $media[] = "\n" . $this->indent($content, 2) . "\n"; + } + + $components = $this->sortImports(array_values(array_unique($components))); + + $types = $resource->isNested() ? "{$type}, {$resource->parentModel()}" : $type; + + $parent_prop = $resource->isNested() ? "\n {$this->parentProp()}: {$resource->parentModel()}" : ''; + $parent_param = $resource->isNested() ? "{$this->parentProp()}, " : ''; + + $name = "{$item}.{$resource->nameField()}"; + + $heading = $this->heading("{{$name}}", 8, raw: true); + + $entries = $this->indent(implode("\n", $entries), 8); + $media = $media ? "\n\n" . $this->indent(implode("\n\n", $media), 6) : ''; + + $label = $resource->singularLower(); + $actions = $this->indent($this->showActions($item, $label), 10); + + $trashed = $resource->hasSoftDeletes() + ? "\n\n {{$item}.deleted_at && (\n \n {{$this->label("This {$label} is deleted.")}}\n \n )}" + : ''; + + if ($resource->hasSoftDeletes()) { + $components = $this->sortImports([...$components, 'Alert']); + } + + $components = implode(', ', $components); + + return << + + +
+ {$heading} + +
+ {$actions} +
+
{$trashed} + + label($resource->singularLabel())}} model={{$item}}> + {$entries} + {$media} + + ) + } + + TSX; + } + + protected function showActions(string $item, string $label): string + { + $resource = $this->resource; + + $confirm = $this->label("Delete this {$label}?"); + + $edit = "{{$item}.can.update && (\n" + . " \n" + . " {{$this->label('Edit')}}\n" + . " \n" + . ')}'; + + $delete = $this->actionButton($item, 'delete', 'destroy', 'delete', 'danger', 'trash-2', 'Delete', $confirm); + + if (! $resource->hasSoftDeletes()) { + return "{$edit}\n{$delete}"; + } + + $restore = $this->actionButton($item, 'restore', 'restore', 'patch', 'success', null, 'Restore'); + $force = $this->actionButton($item, 'forceDelete', 'force_delete', 'delete', 'danger', 'trash-2', 'Delete permanently', $this->label("Permanently delete this {$label}? This cannot be undone.")); + + return "{{$item}.deleted_at ? (\n" + . " <>\n" + . $this->indent("{$restore}\n{$force}", 4) . "\n" + . " \n" + . ") : (\n" + . " <>\n" + . $this->indent("{$edit}\n{$delete}", 4) . "\n" + . " \n" + . ')}'; + } + + protected function actionButton(string $item, string $ability, string $url, string $method, string $color, ?string $icon, string $label, ?string $confirm = null): string + { + $icon = $icon ? "\n startIcon=\"{$icon}\"" : ''; + + $click = $confirm + ? "() => {\n if (window.confirm({$confirm})) router.{$method}({$item}.urls.{$url})\n }" + : "() => router.{$method}({$item}.urls.{$url})"; + + return "{{$item}.can.{$ability} && (\n" + . " \n" + . " {{$this->label($label)}}\n" + . " \n" + . ')}'; + } + + /** + * The page heading, with the breadcrumbs of a nested resource. + */ + protected function heading(string $title, int $spaces, bool $raw = false): string + { + $resource = $this->resource; + $title = $raw ? $title : "{{$this->label($title)}}"; + + $lines = ['
']; + + if ($resource->isNested()) { + $lines[] = " '; + } + + $lines[] = "

{$title}

"; + $lines[] = '
'; + + return $this->indent(implode("\n", $lines), $spaces); + } + + /** + * Indent TSX by a number of spaces. + */ + protected function indent(string $code, int $spaces): string + { + $prefix = str_repeat(' ', $spaces); + + return implode("\n", array_map(fn (string $line) => $line === '' ? '' : $prefix . $line, explode("\n", $code))); + } + + protected function parentProp(): string + { + return Str::camel($this->resource->parentVariable()); + } + + protected function label(string $text): string + { + return '__("' . str_replace('"', '\\"', $text) . '")'; + } + + protected function relationLabel(BelongsToMany $relation): string + { + return Str::ucfirst(str_replace('_', ' ', Str::snake($relation->name))); + } + + protected function relationNameField(BelongsToMany $relation): string + { + return app(ResourceResolver::class)->nameFieldOf($relation->related_table); + } + + /** + * @param array $imports + * @return array + */ + protected function sortImports(array $imports): array + { + $imports = array_values(array_unique($imports)); + + usort($imports, fn (string $a, string $b) => strcasecmp(str_replace('type ', '', $a), str_replace('type ', '', $b))); + + return $imports; + } +} diff --git a/src/Generators/ModelGenerator.php b/src/Generators/ModelGenerator.php index f01629b..d0fdd6a 100644 --- a/src/Generators/ModelGenerator.php +++ b/src/Generators/ModelGenerator.php @@ -2,22 +2,226 @@ namespace Javaabu\Generators\Generators; -use Javaabu\Generators\Generators\Concerns\GeneratesModel; +use Javaabu\Generators\Fields\Field; +use Javaabu\Generators\Fields\ForeignKeyField; +use Javaabu\Generators\Schema\Relations\BelongsTo; +use Javaabu\Generators\Schema\Relations\Relation; +use Javaabu\Generators\Support\Imports; +use Javaabu\Generators\Support\Stub; -class ModelGenerator extends BaseGenerator +class ModelGenerator extends Generator { - use GeneratesModel; + public function files(): array + { + $resource = $this->resource; + + return [new GeneratedFile($resource->classPath($resource->modelClass()), $this->renderModel(), description: 'model')]; + } + + public function renderModel(): string + { + $resource = $this->resource; + $namespace = $resource->namespace('models'); + $imports = new Imports(['Illuminate\\Database\\Eloquent\\Factories\\HasFactory'], $namespace); + + // a javaabu/auth user type extends its User model, which soft deletes + $base = $resource->isUserType() + ? $imports->add('Javaabu\\Auth\\Models\\User') + : $imports->add('Illuminate\\Database\\Eloquent\\Model'); + + $factory = $imports->add($resource->factoryClass()); - protected string $model_stub = 'generators::Models/Model.stub'; + $traits = ["/** @use HasFactory<{$factory}> */", 'use HasFactory;']; + $implements = []; - protected string $model_casts_stub = 'generators::Models/_casts.stub'; + if ($resource->hasSoftDeletes() && ! $resource->isUserType()) { + $traits[] = 'use ' . $imports->add('Illuminate\\Database\\Eloquent\\SoftDeletes') . ';'; + } + + // the admins: their permissions come from javaabu/permissions + if ($resource->isUserType() && ltrim((string) $resource->area('user_model'), '\\') === $resource->modelClass()) { + $traits[] = 'use ' . $imports->add('Javaabu\\Permissions\\Traits\\HasRoles') . ';'; + } + + if ($resource->isTranslatable()) { + $implements[] = $imports->add('Javaabu\\Translatable\\Contracts\\JsonTranslatable'); + $traits[] = 'use ' . $imports->add('Javaabu\\Translatable\\JsonTranslatable\\IsJsonTranslatable') . ';'; + } + + if ($resource->hasMedia()) { + $implements[] = $imports->add('Javaabu\\Mediapicker\\Contracts\\HasAttachments'); + $traits[] = 'use ' . $imports->add('Javaabu\\Mediapicker\\Concerns\\InteractsWithAttachments') . ';'; + } + + $body = $this->blocks([ + $this->renderTranslatable(), + $this->renderCasts($imports), + $this->renderRelations($imports), + $this->renderMedia($imports), + ]); + + return Stub::make('model/Model.stub')->with([ + 'namespace' => $namespace, + 'class' => $resource->model(), + 'base' => $base, + 'implements' => $implements ? ' implements ' . implode(', ', $implements) : '', + 'traits' => implode("\n", array_slice($traits, 0, 2)) . (count($traits) > 2 ? "\n\n" . implode("\n", array_slice($traits, 2)) : ''), + 'fillable' => implode("\n", array_map(fn (string $column) => "'{$column}',", $this->fillable())), + 'body' => $body, + 'imports' => $imports->render(), + ])->render(); + } /** - * Render the model + * @return array */ - public function render(): string + public function fillable(): array + { + $fillable = []; + + /** @var Field $field */ + foreach ($this->resource->fields as $column => $field) { + if ($field->isFillable()) { + $fillable[] = $column; + } + } + + return $fillable; + } + + protected function renderTranslatable(): ?string + { + if (! $this->resource->isTranslatable()) { + return null; + } + + $columns = implode(', ', array_map(fn (string $column) => "'{$column}'", $this->resource->translatable)); + + return << + */ + protected array \$translatable = [{$columns}]; + PHP; + } + + protected function renderCasts(Imports $imports): ?string + { + $casts = []; + + $user_type = $this->resource->isUserType(); + + foreach ($this->resource->fields as $column => $field) { + // the user model casts the account columns + if ($user_type && in_array($column, ['password', 'status'], true)) { + continue; + } + + if ($cast = $field->getCast($imports)) { + $casts[] = " '{$column}' => {$cast},"; + } + } + + if (! $casts) { + return null; + } + + $casts = implode("\n", $casts); + + if ($user_type) { + $casts = " ...parent::casts(),\n{$casts}"; + } + + return << + */ + protected function casts(): array + { + return [ + {$casts} + ]; + } + PHP; + } + + protected function renderRelations(Imports $imports): ?string { - return $this->renderModel(); + $resource = $this->resource; + + /** @var array $relations */ + $relations = [...$resource->belongs_to, ...$resource->has_many, ...$resource->belongs_to_many]; + + $methods = []; + $names = []; + + foreach ($relations as $relation) { + if (in_array($relation->name, $names, true)) { + continue; + } + + // a foreign key left out with --columns has no relation + if ($relation instanceof BelongsTo && ! ($resource->field($relation->foreign_key) instanceof ForeignKeyField)) { + continue; + } + + $names[] = $relation->name; + + $type = $imports->add("Illuminate\\Database\\Eloquent\\Relations\\{$relation->type()}"); + $related_class = $resource->namespace('models') . '\\' . $relation->relatedModel(); + $related = $imports->add($related_class); + + $definition = str_replace("{$relation->relatedModel()}::class", "{$related}::class", $relation->definition()); + $generic = "{$related}, \$this"; + + $methods[] = << + */ + public function {$relation->name}(): {$type} + { + return \$this->{$definition}; + } + PHP; + } + + return $methods ? implode("\n\n", $methods) : null; } + protected function renderMedia(Imports $imports): ?string + { + if (! $this->resource->hasMedia()) { + return null; + } + + $file_types = $imports->add('Javaabu\\Mediapicker\\Support\\FileTypes'); + $file = $imports->add('Spatie\\MediaLibrary\\MediaCollections\\File'); + + $collections = []; + + foreach ($this->resource->media as $media) { + $single = $media->multiple ? '' : "\n ->singleFile()"; + + $collections[] = <<addAttachmentCollection('{$media->name}'){$single} + ->acceptsFile(fn ({$file} \$file) => {$file_types}::isAllowed(\$file->mimeType, '{$media->type}')); + PHP; + } + + $collections = implode("\n\n", $collections); + + return <<hasSoftDeletes() ? 'SoftDeletes' : '') . '.stub'; + $path = app_path('Providers/AppServiceProvider.php'); + + return [new GeneratedFile($path, $this->renderRegistration(), function (string $code) { + $code = PhpFile::addImport($code, 'Javaabu\\Permissions\\Permissions'); + + return PhpFile::appendToMethod($code, 'boot', $this->renderRegistration()); + }, 'permissions')]; } - /** - * Render the policy - */ - public function render(): string + public function renderRegistration(): string { - return $this->renderPermissions(); + $resource = $this->resource; + + $label = str_replace("'", "\\'", $resource->pluralLabel()); + + $statement = "Permissions::resource('{$resource->permissionResource()}', label: '{$label}')"; + + if ($guard = $resource->guard()) { + $statement .= "->guards(['{$guard}'])"; + } + + return $statement . ';'; } } diff --git a/src/Generators/PolicyGenerator.php b/src/Generators/PolicyGenerator.php index 86dbf17..1023e92 100644 --- a/src/Generators/PolicyGenerator.php +++ b/src/Generators/PolicyGenerator.php @@ -2,17 +2,37 @@ namespace Javaabu\Generators\Generators; -use Javaabu\Generators\Generators\Concerns\GeneratesPolicy; +use Javaabu\Generators\Support\Imports; +use Javaabu\Generators\Support\Stub; -class PolicyGenerator extends BaseGenerator +class PolicyGenerator extends Generator { - use GeneratesPolicy; + public function files(): array + { + $resource = $this->resource; + + return [new GeneratedFile($resource->classPath($resource->policyClass()), $this->renderPolicy(), description: 'policy')]; + } - /** - * Render the policy - */ - public function render(): string + public function renderPolicy(): string { - return $this->renderPolicy(); + $resource = $this->resource; + $namespace = $resource->namespace('policies'); + $imports = new Imports(['Javaabu\\Permissions\\Policies\\ResourcePolicy'], $namespace); + + $actions = ['view', 'edit', 'delete', 'force_delete']; + + $guard = $resource->guard() + ? "\n/**\n * Check the permissions of this guard.\n */\nprotected ?string \$guard = '{$resource->guard()}';" + : ''; + + return Stub::make('policy/Policy.stub')->names($resource->table())->with([ + 'namespace' => $namespace, + 'class' => class_basename($resource->policyClass()), + 'resource' => $resource->permissionResource(), + 'permissions' => implode(', ', array_map(fn (string $action) => $resource->permission($action), $actions)), + 'guard' => $guard, + 'imports' => $imports->render(), + ])->render(); } } diff --git a/src/Generators/RequestGenerator.php b/src/Generators/RequestGenerator.php deleted file mode 100644 index 53c2bb1..0000000 --- a/src/Generators/RequestGenerator.php +++ /dev/null @@ -1,20 +0,0 @@ -renderRequest(); - } -} diff --git a/src/Generators/ResourceGenerator.php b/src/Generators/ResourceGenerator.php new file mode 100644 index 0000000..ef2d344 --- /dev/null +++ b/src/Generators/ResourceGenerator.php @@ -0,0 +1,102 @@ +resource; + + return [new GeneratedFile($resource->classPath($resource->resourceClass()), $this->renderResource(), description: 'JSON resource')]; + } + + public function renderResource(): string + { + $resource = $this->resource; + $namespace = $resource->namespace('resources'); + $imports = new Imports(['Illuminate\\Http\\Request', 'Illuminate\\Http\\Resources\\Json\\JsonResource'], $namespace); + + $model = $imports->add($resource->modelClass()); + + $attributes = ["'{$resource->key_name}' => \$this->{$resource->key_name},"]; + + foreach ($resource->fields as $column => $field) { + $attributes[] = "'{$column}' => {$field->getResourceValue()},"; + + if ($field instanceof ForeignKeyField) { + $relation = $field->getRelationName(); + $key = $field->getRelatedKey(); + $name = $field->getRelatedNameField(); + $attribute = $field->getDisplayName(); + + $attributes[] = "'{$attribute}' => \$this->whenLoaded('{$relation}', fn () => [\n '{$key}' => \$this->{$relation}->{$key},\n '{$name}' => \$this->{$relation}->{$name},\n]),"; + } + } + + foreach ($resource->belongs_to_many as $relation) { + $related = $imports->add($resource->namespace('models') . '\\' . $relation->relatedModel()); + $name = app(ResourceResolver::class)->nameFieldOf($relation->related_table); + $item = lcfirst($relation->relatedModel()); + + $attributes[] = "'{$relation->name}' => \$this->whenLoaded('{$relation->name}', fn () => \$this->{$relation->name}->map(fn ({$related} \${$item}) => [\n 'id' => \${$item}->getKey(),\n '{$name}' => \${$item}->{$name},\n])->all()),"; + } + + foreach ($resource->media as $media) { + $attributes[] = $media->multiple + ? "'{$media->name}' => \$this->getAttachmentMedia('{$media->name}')->map(fn (\$media) => \$media->getUrl())->values()->all()," + : "'{$media->name}' => \$this->getFirstAttachmentUrl('{$media->name}') ?: null,"; + } + + if ($resource->hasTimestamps()) { + $attributes[] = "'created_at' => \$this->created_at?->format('Y-m-d H:i'),"; + $attributes[] = "'updated_at' => \$this->updated_at?->format('Y-m-d H:i'),"; + } + + if ($resource->hasSoftDeletes()) { + $attributes[] = "'deleted_at' => \$this->deleted_at?->format('Y-m-d H:i'),"; + } + + $record = $resource->isNested() + ? "[\$this->{$resource->parent->getName()}, \$this->resource]" + : '$this->resource'; + + $urls = [ + "'show' => route('{$resource->routeName('show')}', {$record}),", + "'edit' => route('{$resource->routeName('edit')}', {$record}),", + "'destroy' => route('{$resource->routeName('destroy')}', {$record}),", + ]; + + $abilities = ['view', 'update', 'delete']; + + if ($resource->hasSoftDeletes()) { + $urls[] = "'restore' => route('{$resource->routeName('restore')}', {$record}),"; + $urls[] = "'force_delete' => route('{$resource->routeName('force-delete')}', {$record}),"; + $abilities[] = 'restore'; + $abilities[] = 'forceDelete'; + } + + $attributes[] = "'urls' => [\n" . Stub::indent(implode("\n", $urls)) . "\n],"; + + $can = array_map(fn (string $ability) => "'{$ability}' => (bool) \$request->user()?->can('{$ability}', \$this->resource),", $abilities); + + $attributes[] = "'can' => [\n" . Stub::indent(implode("\n", $can)) . "\n],"; + + return Stub::make('resource/Resource.stub')->with([ + 'namespace' => $namespace, + 'class' => class_basename($resource->resourceClass()), + 'model' => $model, + 'attributes' => implode("\n", $attributes), + 'imports' => $imports->render(), + ])->render(); + } +} diff --git a/src/Generators/RoutesGenerator.php b/src/Generators/RoutesGenerator.php index 88b87bd..8459a06 100644 --- a/src/Generators/RoutesGenerator.php +++ b/src/Generators/RoutesGenerator.php @@ -2,17 +2,165 @@ namespace Javaabu\Generators\Generators; -class RoutesGenerator extends BaseGenerator +use Javaabu\GeneratorHelpers\StringCaser; +use Javaabu\Generators\Support\PhpFile; +use Javaabu\Generators\Support\Stub; + +/** + * Adds the resource, restore, force delete and bulk action routes to the + * admin routes file, creating it (and loading it from routes/web.php) the + * first time. + */ +class RoutesGenerator extends Generator { + public const string MARKER = '// generators: routes'; + + public function files(): array + { + $path = $this->routesPath(); + + $files = [new GeneratedFile($path, $this->renderRoutes(), fn (string $code) => $this->mergeInto($code), 'routes')]; + + $relative = ltrim(substr($path, strlen(base_path('routes'))), '/'); + + if (str_starts_with($path, base_path('routes') . '/') && $relative !== 'web.php') { + $files[] = new GeneratedFile(base_path('routes/web.php'), "require __DIR__ . '/{$relative}';", function (string $code) use ($relative) { + if (str_contains($code, "/{$relative}'")) { + return $code; + } + + return rtrim($code) . "\n\nrequire __DIR__ . '/{$relative}';\n"; + }, 'routes'); + } + + return $files; + } + + public function routesPath(): string + { + return base_path((string) $this->resource->area('routes_file', 'routes/admin.php')); + } + + /** + * A new admin routes file. + */ + public function newRoutesFile(): string + { + $resource = $this->resource; + $middleware = (array) $resource->area('middleware', ['web', 'auth']); + + return Stub::make('routes/admin.stub')->with([ + 'middleware' => "['" . implode("', '", $middleware) . "']", + 'prefix' => trim((string) $resource->area('route_prefix', 'admin'), '/'), + 'name' => $resource->routeNamePrefix(), + ])->render(); + } + + public function mergeInto(string $code): string + { + $resource = $this->resource; + + if (trim($code) === '') { + $code = $this->newRoutesFile(); + } + + $code = PhpFile::addImport($code, $resource->controllerClass()); + $code = PhpFile::addImport($code, $resource->bulkActionsClass()); + + $code = str_contains($code, self::MARKER) + ? PhpFile::insertBeforeMarker($code, self::MARKER, $this->resourceRoutes()) + : rtrim($code) . "\n\n" . $this->resourceRoutes() . "\n"; + + if ($resource->hasMedia() && ! str_contains($code, 'Route::mediapicker(')) { + $code = rtrim($code) . "\n\n" . $this->renderMediapickerRoutes() . "\n"; + } + + return $code; + } + + /** + * The media library the pickers of the admin area pick from (javaabu/mediapicker). + */ + public function renderMediapickerRoutes(): string + { + $resource = $this->resource; + $prefix = trim((string) $resource->area('route_prefix', 'admin'), '/'); + + $options = ["'prefix' => '" . ltrim("{$prefix}/media", '/') . "'", "'name' => '{$resource->routeNamePrefix()}media'"]; + + if ($guard = $resource->guard()) { + array_unshift($options, "'guard' => '{$guard}'"); + } + + $options = implode(', ', $options); + + return "// The media library of the admin area\nRoute::middleware('web')->group(function () {\n Route::mediapicker([{$options}]);\n});"; + } + + public function renderRoutes(): string + { + return $this->resourceRoutes() . ($this->resource->hasMedia() ? "\n\n" . $this->renderMediapickerRoutes() : ''); + } + + public function resourceRoutes(): string + { + $resource = $this->resource; + $controller = class_basename($resource->controllerClass()); + $bulk_actions = class_basename($resource->bulkActionsClass()); + + $name = $resource->routeResource(); + $uri = $this->uri(); + $parameter = '{' . $resource->variable() . '}'; + + $resource_route = "Route::resource('{$name}', {$controller}::class)"; + + if ($resource->isNested()) { + $resource_route .= '->scoped()'; + } + + if ($resource->hasSoftDeletes()) { + $resource_route .= "->withTrashed(['show'])"; + } + + $routes = ["// {$resource->pluralLabel()}", $resource_route . ';']; + + $scoped = $resource->isNested() ? '->scopeBindings()' : ''; + + if ($resource->hasSoftDeletes()) { + $routes[] = "Route::patch('{$uri}/{$parameter}/restore', [{$controller}::class, 'restore']){$scoped}->withTrashed()->name('{$name}.restore');"; + $routes[] = "Route::delete('{$uri}/{$parameter}/force-delete', [{$controller}::class, 'forceDelete']){$scoped}->withTrashed()->name('{$name}.force-delete');"; + } + + $options = []; + + if ($resource->isNested()) { + $options[] = "'name' => '{$name}.bulk'"; + } + + if ($guard = $resource->guard()) { + $options[] = "'guard' => '{$guard}'"; + } + + $options = $options ? ', [' . implode(', ', $options) . ']' : ''; + + $routes[] = "Route::bulkActions('{$uri}', {$bulk_actions}::class{$options});"; + + return implode("\n", $routes); + } + /** - * Render the policy + * The URI of the resource inside the admin group. */ - public function render(): string + public function uri(): string { - $stub = 'generators::routes/_admin' . ($this->hasSoftDeletes() ? 'SoftDeletes' : '') . '.stub'; + $resource = $this->resource; + + if (! $resource->isNested()) { + return $resource->kebab(); + } - $renderer = $this->getRenderer(); + $parent = $resource->parent->getRelatedTable(); - return $renderer->replaceStubNames($stub, $this->getTable()); + return StringCaser::pluralKebab($parent) . '/{' . StringCaser::singularSnake($parent) . '}/' . $resource->kebab(); } } diff --git a/src/Generators/TestGenerator.php b/src/Generators/TestGenerator.php index 1affe06..f727944 100644 --- a/src/Generators/TestGenerator.php +++ b/src/Generators/TestGenerator.php @@ -3,261 +3,580 @@ namespace Javaabu\Generators\Generators; use Illuminate\Support\Str; -use Javaabu\Generators\FieldTypes\BooleanField; -use Javaabu\Generators\FieldTypes\EnumField; -use Javaabu\Generators\FieldTypes\Field; -use Javaabu\Generators\FieldTypes\ForeignKeyField; -use Javaabu\Generators\FieldTypes\JsonField; -use Javaabu\GeneratorHelpers\StringCaser; - -class TestGenerator extends BaseGenerator +use Javaabu\Generators\Fields\Field; +use Javaabu\Generators\Fields\StringField; +use Javaabu\Generators\Support\Imports; +use Javaabu\Generators\Support\Stub; + +/** + * Feature tests for the generated controller: listing, searching, creating, + * validating, showing, updating, deleting, restoring, bulk actions and + * authorization. They run in the app as they are. + */ +class TestGenerator extends Generator { - /** - * Render the controller - */ - public function render(): string + protected Imports $imports; + + public function files(): array { - $stub = 'generators::tests/Model' . ($this->hasSoftDeletes() ? 'SoftDeletes' : '' ) . 'ControllerTest.stub'; + $resource = $this->resource; - $renderer = $this->getRenderer(); + return [new GeneratedFile($resource->classPath($resource->testClass()), $this->renderTest(), description: 'tests')]; + } - $template = $renderer->replaceStubNames($stub, $this->getTable()); + public function renderTest(): string + { + $resource = $this->resource; + $namespace = $resource->namespace('tests'); + + $this->imports = new Imports([ + 'Illuminate\\Foundation\\Testing\\RefreshDatabase', + 'PHPUnit\\Framework\\Attributes\\Test', + 'Tests\\TestCase', + ], $namespace); + + $user = $this->imports->add((string) $resource->area('user_model', 'App\\Models\\User')); + $this->imports->add($resource->modelClass()); + + $tests = array_filter([ + $this->listTest(), + $this->listForbiddenTest(), + $this->searchTest(), + $this->createPageTest(), + $this->storeTest(), + $this->validationTest(), + $this->storeForbiddenTest(), + $this->showTest(), + $this->editPageTest(), + $this->updateTest(), + $this->updateForbiddenTest(), + $this->mediaTest(), + $this->destroyTest(), + ...($resource->hasSoftDeletes() ? [$this->restoreTest(), $this->forceDeleteTest()] : []), + $this->bulkDeleteTest(), + ]); + + $guard = $resource->guard() ? ", '{$resource->guard()}'" : ''; + + $traits = ''; + $set_up = ''; + + if ($resource->isTranslatable()) { + $traits = 'use ' . $this->imports->add('Javaabu\\Translatable\\Testing\\InteractsWithLanguages') . ';'; + $set_up = "\$this->setUpLanguages(['en', 'dv']);"; + } - $use_statements = [ - 'use App\\Exports\\' . StringCaser::pluralStudly($this->getTable()) . 'Export;', - 'use Maatwebsite\\Excel\\Facades\\Excel;', - 'use App\\Models\\' . $this->getModelClass() . ';' - ]; + return Stub::make('test/ControllerTest.stub')->with([ + 'namespace' => $namespace, + 'class' => class_basename($resource->testClass()), + 'tests' => implode("\n\n", $tests), + 'user' => $user, + 'guard' => $guard, + 'traits_before' => $traits, + 'setUp' => $set_up, + 'imports' => $this->imports->render(), + ])->render(); + } - /** - * @var string $column - * @var Field $field - */ - foreach ($this->getFields() as $column => $field) { - if ($field instanceof ForeignKeyField) { - $import = 'use App\\Models\\' . $field->getRelatedModelClass() . ';'; - - if (! in_array($import, $use_statements)) { - $use_statements[] = $import; - } - } + /* + |-------------------------------------------------------------------------- + | Tests + |-------------------------------------------------------------------------- + */ - if ($field instanceof EnumField && $field->hasEnumClass()) { - $enum_import = 'use ' . $field->getEnumClass() . ';'; - if (! in_array($enum_import, $use_statements)) { - $use_statements[] = $enum_import; - } - } + protected function listTest(): string + { + $resource = $this->resource; + $variable = '$' . $resource->variable(); + + $body = $this->parentSetup() + . "{$variable} = {$this->factory()}->create();\n\n" + . "\$this->actingAsAdmin('{$resource->permission('view')}');\n\n" + . "\$this->get({$resource->route('index')})\n" + . " ->assertOk()\n" + . $this->assertPage('Index', "has('" . Str::camel($resource->pluralVariable()) . ".data', 1)", $this->seeName($variable)); + + return $this->test("it_lists_the_{$resource->plural()}", $body); + } + + protected function listForbiddenTest(): string + { + $resource = $this->resource; + + $body = $this->parentSetup() + . "\$this->actingAsAdmin();\n\n" + . "\$this->get({$resource->route('index')})\n" + . ' ->assertForbidden();'; + + return $this->test("it_does_not_list_the_{$resource->plural()}_without_permission", $body); + } + + protected function searchTest(): ?string + { + $resource = $this->resource; + $name = $resource->nameField(); + $field = $resource->field($name); + + if (! $field instanceof StringField || $field->getKind() !== 'text' || $field->getMax() < 20) { + return null; } - $template = $renderer->appendMultipleContent([ - [ - 'search' => "// use statements\n", - 'keep_search' => false, - 'content' => $use_statements ? implode("\n", $use_statements) . "\n" : '', - ], - [ - 'search' => $renderer->addIndentation("// errors\n", 4), - 'keep_search' => false, - 'content' => $this->renderErrors(), - ], - [ - 'search' => $renderer->addIndentation("// factory inputs\n", 3), - 'keep_search' => false, - 'content' => $this->renderFactoryInputs(), - ], - [ - 'search' => $renderer->addIndentation("// factory db values\n", 3), - 'keep_search' => false, - 'content' => $this->renderFactoryDbValues(), - ], - [ - 'search' => $renderer->addIndentation("// correct inputs\n", 3), - 'keep_search' => false, - 'content' => $this->renderCorrectInputs(), - ], - [ - 'search' => $renderer->addIndentation("// new foreign key models\n", 2), - 'keep_search' => false, - 'content' => $this->renderForeignKeyModels('new_'), - ], - [ - 'search' => $renderer->addIndentation("// old foreign key models\n", 2), - 'keep_search' => false, - 'content' => $this->renderForeignKeyModels('old_'), - ], - [ - 'search' => $renderer->addIndentation("// correct db values\n", 3), - 'keep_search' => false, - 'content' => $this->renderCorrectDbValues(), - ], - [ - 'search' => $renderer->addIndentation("// correct factory values\n", 3), - 'keep_search' => false, - 'content' => $this->renderCorrectFactoryValues(), - ], - [ - 'search' => $renderer->addIndentation("// different correct inputs\n", 3), - 'keep_search' => false, - 'content' => $this->renderDifferentCorrectInputs(), - ], - [ - 'search' => $renderer->addIndentation("// different correct db values\n", 3), - 'keep_search' => false, - 'content' => $this->renderDifferentCorrectDbValues(), - ], - [ - 'search' => $renderer->addIndentation("// wrong inputs\n", 3), - 'keep_search' => false, - 'content' => $this->renderWrongInputs(), - ], - [ - 'search' => $renderer->addIndentation("// wrong db values\n", 3), - 'keep_search' => false, - 'content' => $this->renderWrongDbValues(), - ], - [ - 'search' => '{{keyName}}', - 'keep_search' => false, - 'content' => $this->getKeyName(), - ], - [ - 'search' => '{{tableName}}', - 'keep_search' => false, - 'content' => $this->getTable(), - ], - [ - 'search' => '{{nameField}}', - 'keep_search' => false, - 'content' => $this->getNameField(), - ], - ], $template); - - return $template; + if (! in_array($name, $resource->searchableColumns(), true)) { + return null; + } + + $variable = '$' . $resource->variable(); + $plural = Str::camel($resource->pluralVariable()); + + $route = $resource->isNested() + ? "route('{$resource->routeName('index')}', [\${$resource->parentVariable()}, 'search' => 'ރަސްމީ'])" + : "route('{$resource->routeName('index')}', ['search' => 'ރަސްމީ'])"; + + $body = $this->parentSetup() + . "{$variable} = {$this->factory()}->create(['{$name}' => 'ރަސްމީ ދުވަސް']);\n" + . "\$other = {$this->factory()}->create(['{$name}' => 'Something else']);\n\n" + . "\$this->actingAsAdmin('{$resource->permission('view')}');\n\n" + . "\$this->get({$route})\n" + . " ->assertOk()\n" + . ($resource->isInertia() + ? $this->assertPage('Index', "has('{$plural}.data', 1)\n ->where('{$plural}.data.0.{$resource->key_name}', {$variable}->{$resource->key_name})", null) + : " ->assertSee('ރަސްމީ ދުވަސް')\n ->assertDontSee('Something else');"); + + return $this->test("it_searches_the_{$resource->plural()}", $body); + } + + protected function createPageTest(): string + { + $resource = $this->resource; + + $body = $this->parentSetup() + . "\$this->actingAsAdmin('{$resource->permission('edit')}');\n\n" + . "\$this->get({$resource->route('create')})\n" + . " ->assertOk()\n" + . $this->assertPage('Create', "has('form')", " ->assertSee(__('New {$this->escape($resource->singularLower())}'));"); + + return $this->test("it_shows_the_new_{$resource->singular()}_form", $body); } - public function renderForeignKeyModels($prefix = ''): string + protected function storeTest(): string { - $models = ''; - $fields = $this->getFields(); - $renderer = $this->getRenderer(); - - /** - * @var string $column - * @var Field $field - */ - foreach ($fields as $column => $field) { - if ($field instanceof ForeignKeyField) { - $models .= $renderer->addIndentation($field->generateTestFactoryStatement($prefix) . "\n", 2); + $resource = $this->resource; + $variable = '$' . $resource->variable(); + + [$setup, $inputs, $database, $assertions] = $this->inputs('valid'); + + $parent = $resource->isNested() + ? "\n '{$resource->parent->getName()}' => \${$resource->parentVariable()}->{$resource->parentKey()}," + : ''; + + $body = $this->parentSetup() + . $setup + . "\$this->actingAsAdmin('{$resource->permission('edit')}');\n\n" + . "\$this->post({$resource->route('store')}, [\n{$inputs}\n])\n" + . " ->assertRedirect()\n" + . " ->assertSessionHasNoErrors();\n\n" + . "\$this->assertDatabaseHas('{$resource->table()}', [{$parent}\n{$database}\n]);"; + + if ($assertions) { + $body .= "\n\n{$variable} = {$this->model()}::query()->latest('{$resource->key_name}')->first();\n\n{$assertions}"; + } + + return $this->test("it_creates_a_{$resource->singular()}", $body); + } + + protected function validationTest(): ?string + { + $resource = $this->resource; + + $inputs = []; + $errors = []; + + foreach ($resource->formFields() as $column => $field) { + if (in_array($column, $resource->translatable, true)) { + continue; } + + $invalid = $field->getInvalidValue(); + + if ($invalid === null) { + continue; + } + + $inputs[] = " '{$field->getInputName()}' => {$invalid},"; + $errors[] = "'{$field->getInputName()}'"; } - return $models ? $models . "\n" : ''; + if (! $errors) { + return null; + } + + $body = $this->parentSetup() + . "\$this->actingAsAdmin('{$resource->permission('edit')}');\n\n" + . "\$this->post({$resource->route('store')}, [\n" . implode("\n", $inputs) . "\n])\n" + . ' ->assertSessionHasErrors([' . implode(', ', $errors) . "]);\n\n" + . "\$this->assertDatabaseCount('{$resource->table()}', 0);"; + + return $this->test("it_validates_new_{$resource->plural()}", $body); } - public function renderErrors(): string + protected function storeForbiddenTest(): string { - $errors = ''; - $fields = $this->getFields(); - $renderer = $this->getRenderer(); - - /** - * @var string $column - * @var Field $field - */ - foreach ($fields as $column => $field) { - $errors .= $renderer->addIndentation("'" . $field->getInputName() . "',\n", 4); + $resource = $this->resource; + + $body = $this->parentSetup() + . "\$this->actingAsAdmin('{$resource->permission('view')}');\n\n" + . "\$this->post({$resource->route('store')}, [])\n" + . " ->assertForbidden();\n\n" + . "\$this->assertDatabaseCount('{$resource->table()}', 0);"; + + return $this->test("it_does_not_create_{$resource->plural()}_without_permission", $body); + } + + protected function showTest(): string + { + $resource = $this->resource; + $variable = '$' . $resource->variable(); + + $body = $this->parentSetup() + . "{$variable} = {$this->factory()}->create();\n\n" + . "\$this->actingAsAdmin('{$resource->permission('view')}');\n\n" + . "\$this->get({$resource->route('show', $variable)})\n" + . " ->assertOk()\n" + . $this->assertPage('Show', "where('" . Str::camel($resource->variable()) . ".{$resource->key_name}', {$variable}->{$resource->key_name})", $this->seeName($variable)); + + return $this->test("it_shows_a_{$resource->singular()}", $body); + } + + protected function editPageTest(): string + { + $resource = $this->resource; + $variable = '$' . $resource->variable(); + + $body = $this->parentSetup() + . "{$variable} = {$this->factory()}->create();\n\n" + . "\$this->actingAsAdmin('{$resource->permission('edit')}');\n\n" + . "\$this->get({$resource->route('edit', $variable)})\n" + . " ->assertOk()\n" + . $this->assertPage('Edit', "has('form')", " ->assertSee(__('Edit {$this->escape($resource->singularLower())}'));"); + + return $this->test("it_shows_the_edit_{$resource->singular()}_form", $body); + } + + protected function updateTest(): string + { + $resource = $this->resource; + $variable = '$' . $resource->variable(); + + [$setup, $inputs, $database, $assertions] = $this->inputs('other'); + + $body = $this->parentSetup() + . $setup + . "{$variable} = {$this->factory()}->create();\n\n" + . "\$this->actingAsAdmin('{$resource->permission('edit')}');\n\n" + . "\$this->patch({$resource->route('update', $variable)}, [\n{$inputs}\n])\n" + . " ->assertRedirect()\n" + . " ->assertSessionHasNoErrors();\n\n" + . "\$this->assertDatabaseHas('{$resource->table()}', [\n '{$resource->key_name}' => {$variable}->{$resource->key_name},\n{$database}\n]);"; + + if ($assertions) { + $body .= "\n\n{$variable}->refresh();\n\n{$assertions}"; } - return $errors; + return $this->test("it_updates_a_{$resource->singular()}", $body); + } + + protected function updateForbiddenTest(): string + { + $resource = $this->resource; + $variable = '$' . $resource->variable(); + + $body = $this->parentSetup() + . "{$variable} = {$this->factory()}->create();\n\n" + . "\$this->actingAsAdmin('{$resource->permission('view')}');\n\n" + . "\$this->patch({$resource->route('update', $variable)}, [])\n" + . ' ->assertForbidden();'; + + return $this->test("it_does_not_update_{$resource->plural()}_without_permission", $body); } - protected function renderValues(string $key_callback, string $value_callback, int $tabs = 3, string $value_prefix = ''): string + /** + * Picks media from the user's library, when the admin users have one + * (javaabu/mediapicker's MediaOwner). + */ + protected function mediaTest(): ?string { - $values = ''; - $fields = $this->getFields(); - $renderer = $this->getRenderer(); - - /** - * @var string $column - * @var Field $field - */ - foreach ($fields as $column => $field) { - $value = $field->{$value_callback}(); - $key = $field->{$key_callback}(); - - $values .= $renderer->addIndentation("'$key' => $value_prefix$value,\n", $tabs); + $resource = $this->resource; + $user_model = (string) $resource->area('user_model', 'App\\Models\\User'); + + if (! $resource->hasMedia()) { + return null; + } + + if (! interface_exists('Javaabu\\Mediapicker\\Contracts\\MediaOwner') || ! class_exists($user_model) || ! is_subclass_of($user_model, 'Javaabu\\Mediapicker\\Contracts\\MediaOwner')) { + return null; } - return $values; + $variable = '$' . $resource->variable(); + $storage = $this->imports->add('Illuminate\\Support\\Facades\\Storage'); + $file = $this->imports->add('Illuminate\\Http\\UploadedFile'); + + [$setup, $inputs] = $this->inputs('other'); + + $uploads = []; + $media_inputs = []; + $assertions = []; + + foreach ($resource->media as $media) { + $upload = $media->type === 'image' + ? "{$file}::fake()->image('{$media->name}.jpg')" + : "{$file}::fake()->createWithContent('{$media->name}.pdf', \"%PDF-1.4\\n%%EOF\\n\")"; + + $uploads[] = "\${$media->name} = \$user->addMedia({$upload})->toMediaCollection(\$user->getMediapickerCollectionName());"; + $media_inputs[] = $media->multiple + ? " '{$media->name}' => [\${$media->name}->id]," + : " '{$media->name}' => \${$media->name}->id,"; + $assertions[] = "\$this->assertEquals(\${$media->name}->id, {$variable}->getFirstAttachmentMedia('{$media->name}')?->id);"; + } + + $body = $this->parentSetup() + . $setup + . "{$storage}::fake(config('media-library.disk_name'));\n\n" + . "\$user = \$this->actingAsAdmin('{$resource->permission('edit')}');\n\n" + . implode("\n", $uploads) . "\n\n" + . "{$variable} = {$this->factory()}->create();\n\n" + . "\$this->patch({$resource->route('update', $variable)}, [\n{$inputs}\n" . implode("\n", $media_inputs) . "\n])\n" + . " ->assertRedirect()\n" + . " ->assertSessionHasNoErrors();\n\n" + . implode("\n", $assertions); + + return $this->test("it_attaches_media_to_a_{$resource->singular()}", $body); } - public function renderWrongInputs(): string + protected function destroyTest(): string { - return $this->renderValues('getInputName', 'generateWrongValue'); + $resource = $this->resource; + $variable = '$' . $resource->variable(); + + $assert = $resource->hasSoftDeletes() + ? "\$this->assertSoftDeleted({$variable});" + : "\$this->assertModelMissing({$variable});"; + + $body = $this->parentSetup() + . "{$variable} = {$this->factory()}->create();\n\n" + . "\$this->actingAsAdmin('{$resource->permission('delete')}');\n\n" + . "\$this->delete({$resource->route('destroy', $variable)})\n" + . " ->assertRedirect({$resource->route('index')});\n\n" + . $assert; + + return $this->test("it_deletes_a_{$resource->singular()}", $body); } - public function renderWrongDbValues(): string + protected function restoreTest(): string { - return $this->renderValues('getName', 'generateWrongDbValue'); + $resource = $this->resource; + $variable = '$' . $resource->variable(); + + $body = $this->parentSetup() + . "{$variable} = {$this->factory()}->create();\n" + . "{$variable}->delete();\n\n" + . "\$this->actingAsAdmin('{$resource->permission('delete')}');\n\n" + . "\$this->patch({$resource->route('restore', $variable)})\n" + . " ->assertRedirect();\n\n" + . "\$this->assertNotSoftDeleted({$variable});"; + + return $this->test("it_restores_a_deleted_{$resource->singular()}", $body); } - public function renderCorrectInputs(): string + protected function forceDeleteTest(): string { - return $this->renderValues('getInputName', 'generateCorrectValue'); + $resource = $this->resource; + $variable = '$' . $resource->variable(); + + $body = $this->parentSetup() + . "{$variable} = {$this->factory()}->create();\n" + . "{$variable}->delete();\n\n" + . "\$this->actingAsAdmin('{$resource->permission('force_delete')}');\n\n" + . "\$this->delete({$resource->route('force-delete', $variable)})\n" + . " ->assertRedirect({$resource->route('index')});\n\n" + . "\$this->assertModelMissing({$variable});"; + + return $this->test("it_permanently_deletes_a_{$resource->singular()}", $body); } - public function renderCorrectDbValues(): string + protected function bulkDeleteTest(): string { - return $this->renderValues('getName', 'generateCorrectDbValue'); + $resource = $this->resource; + $plural = '$' . $resource->pluralVariable(); + + $assert = $resource->hasSoftDeletes() + ? "{$plural}->each(fn ({$this->model()} \$record) => \$this->assertSoftDeleted(\$record));" + : "{$plural}->each(fn ({$this->model()} \$record) => \$this->assertModelMissing(\$record));"; + + $body = $this->parentSetup() + . "{$plural} = {$this->factory()}->count(2)->create();\n\n" + . "\$this->actingAsAdmin('{$resource->permission('view')}', '{$resource->permission('delete')}');\n\n" + . "\$this->postJson({$resource->route('bulk')}, [\n" + . " 'action' => 'delete',\n" + . " '{$resource->pluralVariable()}' => {$plural}->pluck('{$resource->key_name}')->all(),\n" + . "])\n" + . " ->assertOk()\n" + . " ->assertJson(['succeeded' => 2]);\n\n" + . $assert; + + return $this->test("it_deletes_{$resource->plural()}_in_bulk", $body); } - public function renderCorrectFactoryValues(): string + /* + |-------------------------------------------------------------------------- + | Helpers + |-------------------------------------------------------------------------- + */ + + /** + * The setup, inputs, database values and other assertions for a request + * with the valid (or the other valid) values. + * + * @return array{0: string, 1: string, 2: string, 3: string} + */ + protected function inputs(string $set): array { - return $this->renderValues('getName', 'generateCorrectValue'); + $resource = $this->resource; + $variable = '$' . $resource->variable(); + + $setup = []; + $inputs = []; + $database = []; + $assertions = []; + + /** @var Field $field */ + foreach ($resource->formFields() as $column => $field) { + foreach ($field->getTestSetup($this->imports, '') as $line) { + $setup[] = $line; + } + + $value = $set === 'valid' ? $field->getValidValue() : $field->getOtherValidValue(); + $name = $field->getInputName(); + + if (in_array($column, $resource->translatable, true)) { + $dhivehi = $set === 'valid' ? "'ދިވެހި {$column}'" : "'އެހެން {$column}'"; + + $inputs[] = " '{$name}_en' => {$value},"; + $inputs[] = " '{$name}_dv' => {$dhivehi},"; + $assertions[] = "\$this->assertEquals({$dhivehi}, {$variable}->translate('{$column}', 'dv', fallback: false));"; + $database[] = " '{$column}' => {$value},"; + + continue; + } + + $inputs[] = " '{$name}' => {$value},"; + + $database_value = $field->getDatabaseValue($value); + + if ($database_value !== null) { + $database[] = " '{$column}' => {$database_value},"; + + continue; + } + + if ($assertion = $field->getSavedValueAssertion($variable, $value, $this->imports)) { + $assertions[] = $assertion; + } + } + + foreach ($resource->formRelations() as $relation) { + $related = $this->imports->add($resource->namespace('models') . '\\' . $relation->relatedModel()); + $items = '$' . Str::snake($relation->name); + + $setup[] = "{$items} = {$related}::factory()->count(2)->create();"; + + $ids = $set === 'valid' ? "{$items}->pluck('id')->all()" : "[{$items}->first()->id]"; + + $inputs[] = " '{$relation->name}' => {$ids},"; + $assertions[] = "\$this->assertEquals({$ids}, {$variable}->{$relation->name}()->pluck('{$relation->related_table}.id')->sort()->values()->all());"; + } + + $setup = array_values(array_unique($setup)); + + return [ + $setup ? implode("\n", $setup) . "\n\n" : '', + implode("\n", $inputs), + implode("\n", $database), + implode("\n", $assertions), + ]; } - public function renderDifferentCorrectInputs(): string + protected function parentSetup(): string { - return $this->renderValues('getInputName', 'generateDifferentCorrectValue'); + $resource = $this->resource; + + if (! $resource->isNested()) { + return ''; + } + + $parent = $this->imports->add($resource->parentModelClass()); + + return "\${$resource->parentVariable()} = {$parent}::factory()->create();\n"; } - public function renderDifferentCorrectDbValues(): string + /** + * Product::factory(), or Product::factory()->for($category, 'category') when nested. + */ + protected function factory(): string { - return $this->renderValues('getName', 'generateDifferentCorrectDbValue'); + $resource = $this->resource; + $factory = "{$this->model()}::factory()"; + + if ($resource->isNested()) { + $factory .= "->for(\${$resource->parentVariable()}, '{$resource->parent->getRelationName()}')"; + } + + return $factory; } - public function renderFactoryInputs(): string + protected function model(): string { - return $this->renderValues('getInputName', 'generateFactoryInput', value_prefix: '$' . $this->getMorph() . '->'); + return $this->imports->add($this->resource->modelClass()); } - public function renderFactoryDbValues(): string + protected function seeName(string $variable): string { - $values = ''; - $value_callback = 'generateFactoryDbValue'; - $key_callback = 'getName'; - $fields = $this->getFields(); - $renderer = $this->getRenderer(); - $value_prefix = '$' . $this->getMorph() . '->'; - $tabs = 3; - - /** - * @var string $column - * @var Field $field - */ - foreach ($fields as $column => $field) { - $value = $field->{$value_callback}(); - $key = $field->{$key_callback}(); - - $generated_value = $value_prefix . $value; - - $generated_value = $field->formatFactoryDbValue($generated_value); - - $values .= $renderer->addIndentation("'$key' => $generated_value,\n", $tabs); + $resource = $this->resource; + + if (! $resource->field($resource->nameField())) { + return ' ;'; + } + + return " ->assertSee({$variable}->{$resource->nameField()});"; + } + + /** + * The assertion on the response: the Inertia page, or the Blade view. + */ + protected function assertPage(string $page, string $inertia, ?string $blade): string + { + $resource = $this->resource; + + if (! $resource->isInertia()) { + return $blade ?? ' ;'; } - return $values; + $assert = $this->imports->add('Inertia\\Testing\\AssertableInertia'); + + return " ->assertInertia(fn ({$assert} \$page) => \$page\n" + . " ->component('{$resource->pageName($page)}')\n" + . " ->{$inertia}\n" + . ' );'; + } + + protected function test(string $name, string $body): string + { + $body = Stub::indent(str_replace("\n ;", ';', $body)); + + return "#[Test]\npublic function {$name}(): void\n{\n{$body}\n}"; } + protected function escape(string $value): string + { + return str_replace("'", "\\'", $value); + } } diff --git a/src/Generators/TranslationsGenerator.php b/src/Generators/TranslationsGenerator.php new file mode 100644 index 0000000..7a3e01c --- /dev/null +++ b/src/Generators/TranslationsGenerator.php @@ -0,0 +1,47 @@ +phrases(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), function (string $json) { + $current = $json === '' ? [] : (json_decode($json, true) ?: []); + + $merged = $current + $this->phrases(); + + if ($merged === $current && $json !== '') { + return $json; + } + + return json_encode($merged, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + }, 'Dhivehi translations')]; + } + + /** + * @return array + */ + public function phrases(): array + { + $phrases = (array) trans('generators::phrases', [], 'dv'); + + $used = ['Search', 'Created at', 'Updated at', 'Edit', 'View', 'Delete', 'Breadcrumb', 'ID']; + + if ($this->resource->hasSoftDeletes()) { + $used = [...$used, 'Restore', 'Delete permanently']; + } + + if ($this->resource->hasMedia()) { + $used[] = 'Media'; + } + + return array_intersect_key($phrases, array_flip($used)); + } +} diff --git a/src/Generators/ViewsGenerator.php b/src/Generators/ViewsGenerator.php deleted file mode 100644 index c38bf5f..0000000 --- a/src/Generators/ViewsGenerator.php +++ /dev/null @@ -1,386 +0,0 @@ - StringCaser::pluralKebab($this->getTable()) . '.blade.php', - 'renderActions' => '_actions.blade.php', - 'renderForm' => '_form.blade.php', - 'renderCreateView' => 'create.blade.php', - 'renderEditView' => 'edit.blade.php', - 'renderInfolist' => '_details.blade.php', - 'renderShowView' => 'show.blade.php', - 'renderBulkActions' => '_bulk.blade.php', - 'renderTableRows' => '_list.blade.php', - 'renderTable' => '_table.blade.php', - 'renderFilters' => '_filter.blade.php', - 'renderIndexView' => 'index.blade.php', - ]; - } - - /** - * Render the views - */ - public function render(): string - { - - $output = ''; - - $views = $this->viewsToRender(); - - foreach ($views as $method => $file_name) { - $output .= "// $file_name\n"; - $output .= $this->{$method}(); - } - - return $output; - } - - /** - * Render the info list - */ - public function renderTableTitles(): string - { - $renderer = $this->getRenderer(); - - $table_columns = []; - $name_field = $this->getNameField(); - - /** - * @var string $column - * @var Field $field - */ - foreach ($this->getFields() as $column => $field) { - if ($column != $name_field) { - $table_columns[] = $renderer->addIndentation($this->getTableTitleComponentBlade($column), 2); - } - } - - return $table_columns ? implode("\n", $table_columns) . "\n" : ''; - } - - /** - * Render the info list - */ - public function renderTableColumns(): string - { - $renderer = $this->getRenderer(); - - $table_columns = []; - $name_field = $this->getNameField(); - - /** - * @var string $column - * @var Field $field - */ - foreach ($this->getFields() as $column => $field) { - if ($column != $name_field) { - $table_columns[] = $renderer->addIndentation($this->getTableCellComponentBlade($column) . "\n", 2); - } - } - - return $table_columns ? implode("\n", $table_columns) : ''; - } - - /** - * Render the info list - */ - public function renderInfolist(): string - { - $stub = 'generators::views/model/_details.blade.stub'; - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - $form_components = []; - - /** - * @var string $column - * @var Field $field - */ - foreach ($this->getFields() as $column => $field) { - $form_components[] = $renderer->addIndentation($this->getEntryComponentBlade($column) . "\n", 1); - } - - $template = $renderer->appendMultipleContent([ - [ - 'search' => $renderer->addIndentation("// entries\n", 1), - 'keep_search' => false, - 'content' => $form_components ? implode("\n", $form_components) : '', - ], - ], $template); - - return $template; - } - - /** - * Render the form - */ - public function renderForm(): string - { - $stub = 'generators::views/model/_form.blade.stub'; - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - $form_components = []; - - /** - * @var string $column - * @var Field $field - */ - foreach ($this->getFields() as $column => $field) { - $form_components[] = $renderer->addIndentation($this->getFormComponentBlade($column) . "\n", 1); - } - - $template = $renderer->appendMultipleContent([ - [ - 'search' => "\n", - 'keep_search' => true, - 'content' => $form_components ? implode("\n", $form_components) . "\n" : '', - ], - ], $template); - - return $template; - } - - /** - * Render the layout - */ - public function renderLayout(): string - { - $stub = 'generators::views/model/model.blade.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } - - public function renderCreateView(): string - { - $stub = 'generators::views/model/create.blade.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } - - public function renderEditView(): string - { - $stub = 'generators::views/model/edit.blade.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } - - public function renderShowView(): string - { - $stub = 'generators::views/model/show.blade.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } - - public function renderIndexView(): string - { - $stub = 'generators::views/model' . ($this->hasSoftDeletes() ? '-soft-deletes' : '') . '/index.blade.stub'; - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - - return $renderer->appendMultipleContent([ - [ - 'search' => '{{icon}}', - 'keep_search' => false, - 'content' => $this->getIcon(), - ], - ], $template); - } - - public function renderSidebarLinks(): string - { - $stub = 'generators::Menus/_sidebar.stub'; - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - - return $renderer->appendMultipleContent([ - [ - 'search' => '{{icon}}', - 'keep_search' => false, - 'content' => $this->getSidebarIconPrefix() . $this->getIcon(false), - ], - ], $template); - } - - /** - * Render the table - */ - public function renderTable(): string - { - $stub = 'generators::views/model/_table.blade.stub'; - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - $columns = $this->renderTableTitles(); - $column_count = $this->fieldsCount(); - - if (! $this->isNameFieldIncludedInColumns()) { - $column_count++; - } - - $template = $renderer->appendMultipleContent([ - [ - 'search' => '{{nameField}}', - 'keep_search' => false, - 'content' => $this->getNameField(), - ], - [ - 'search' => '{{nameLabel}}', - 'keep_search' => false, - 'content' => $this->getNameLabel(), - ], - [ - 'search' => '{{columnCount}}', - 'keep_search' => false, - 'content' => $column_count, - ], - [ - 'search' => $renderer->addIndentation("// titles\n", 2), - 'keep_search' => false, - 'content' => $columns, - ], - ], $template); - - return $template; - } - - /** - * Render the table rows - */ - public function renderTableRows(): string - { - $stub = 'generators::views/model' . ($this->hasSoftDeletes() ? '-soft-deletes' : '') . '/_list.blade.stub'; - - $renderer = $this->getRenderer(); - - $template = $renderer->replaceStubNames($stub, $this->getTable()); - $columns = $this->renderTableColumns(); - - $template = $renderer->appendMultipleContent([ - [ - 'search' => '{{keyName}}', - 'keep_search' => false, - 'content' => $this->getKeyName(), - ], - [ - 'search' => '{{nameLabel}}', - 'keep_search' => false, - 'content' => $this->getNameLabel(), - ], - [ - 'search' => $renderer->addIndentation("// columns\n", 2), - 'keep_search' => false, - 'content' => $columns ? "\n" . $columns : '', - ], - ], $template); - - return $template; - } - - public function renderFilters(): string - { - $stub = 'generators::views/model' . ($this->hasSoftDeletes() ? '-soft-deletes' : '') . '/_filter.blade.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } - - public function renderActions(): string - { - $stub = 'generators::views/model' . ($this->hasSoftDeletes() ? '-soft-deletes' : '') . '/_actions.blade.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } - - public function renderBulkActions(): string - { - $stub = 'generators::views/model/_bulk.blade.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } - - /** - * Get the blade code for the column - */ - public function getFormComponentBlade(string $column): ?string - { - $field = $this->getField($column); - - if (! $field) { - return null; - } - - return $field->renderFormComponent(); - } - - /** - * Get the blade code for the column - */ - public function getEntryComponentBlade(string $column): ?string - { - $field = $this->getField($column); - - if (! $field) { - return null; - } - - return $field->renderEntryComponent(); - } - - /** - * Get the blade code for the column - */ - public function getTableCellComponentBlade(string $column): ?string - { - $field = $this->getField($column); - - if (! $field) { - return null; - } - - return $field->renderTableCellComponent(); - } - - /** - * Get the blade code for the column - */ - public function getTableTitleComponentBlade(string $column): ?string - { - $field = $this->getField($column); - - if (! $field) { - return null; - } - - return $field->renderTableTitleComponent(); - } -} diff --git a/src/GeneratorsServiceProvider.php b/src/GeneratorsServiceProvider.php index 88c6315..d1e2402 100644 --- a/src/GeneratorsServiceProvider.php +++ b/src/GeneratorsServiceProvider.php @@ -2,119 +2,40 @@ namespace Javaabu\Generators; -use Illuminate\Filesystem\Filesystem; +use Illuminate\Database\DatabaseManager; use Illuminate\Support\ServiceProvider; -use Javaabu\Generators\Commands\Auth\Controllers\GenerateAuthConfirmPasswordControllerCommand; -use Javaabu\Generators\Commands\Auth\Controllers\GenerateAuthControllerCommand; -use Javaabu\Generators\Commands\Auth\Controllers\GenerateAuthForgotPasswordControllerCommand; -use Javaabu\Generators\Commands\Auth\Controllers\GenerateAuthHomeControllerCommand; -use Javaabu\Generators\Commands\Auth\Controllers\GenerateAuthLoginControllerCommand; -use Javaabu\Generators\Commands\Auth\Controllers\GenerateAuthRegisterControllerCommand; -use Javaabu\Generators\Commands\Auth\Controllers\GenerateAuthResetPasswordControllerCommand; -use Javaabu\Generators\Commands\Auth\Controllers\GenerateAuthUpdatePasswordControllerCommand; -use Javaabu\Generators\Commands\Auth\Controllers\GenerateAuthVerificationControllerCommand; -use Javaabu\Generators\Commands\Auth\GenerateAuthConfigCommand; -use Javaabu\Generators\Commands\Auth\GenerateAuthFactoryCommand; -use Javaabu\Generators\Commands\Auth\GenerateAuthModelCommand; -use Javaabu\Generators\Commands\Auth\GenerateAuthPasswordResetsCommand; -use Javaabu\Generators\Commands\Auth\GenerateAuthPermissionsCommand; -use Javaabu\Generators\Commands\Auth\GenerateAuthPolicyCommand; -use Javaabu\Generators\Commands\Auth\GenerateAuthRequestCommand; -use Javaabu\Generators\Commands\GenerateAllCommand; -use Javaabu\Generators\Commands\GenerateApiCommand; -use Javaabu\Generators\Commands\GenerateApiControllerCommand; -use Javaabu\Generators\Commands\GenerateApiTestCommand; -use Javaabu\Generators\Commands\GenerateControllerCommand; -use Javaabu\Generators\Commands\GenerateExportCommand; -use Javaabu\Generators\Commands\GenerateFactoryCommand; -use Javaabu\Generators\Commands\GenerateModelCommand; -use Javaabu\Generators\Commands\GeneratePermissionsCommand; -use Javaabu\Generators\Commands\GeneratePolicyCommand; -use Javaabu\Generators\Commands\GenerateRequestCommand; -use Javaabu\Generators\Commands\GenerateRollbackCommand; -use Javaabu\Generators\Commands\GenerateRoutesCommand; -use Javaabu\Generators\Commands\GenerateTestCommand; -use Javaabu\Generators\Commands\GenerateViewsCommand; -use Javaabu\Generators\Contracts\SchemaResolverInterface; -use Javaabu\Generators\Exceptions\UnsupportedDbDriverException; -use Javaabu\Generators\Resolvers\SchemaResolverMySql; use Javaabu\GeneratorHelpers\StubRenderer; +use Javaabu\Generators\Schema\RelationResolver; +use Javaabu\Generators\Schema\SchemaInspector; class GeneratorsServiceProvider extends ServiceProvider { - /** - * Bootstrap the application services. - */ - public function boot() + public function register(): void { - // declare publishes - if ($this->app->runningInConsole()) { - $this->publishes([ - __DIR__ . '/../config/config.php' => config_path('generators.php'), - ], 'generators-config'); + $this->mergeConfigFrom(__DIR__ . '/../config/generators.php', 'generators'); - $this->publishes([ - __DIR__ . '/../stubs' => base_path('stubs/vendor/generators'), - ], 'generators-stubs'); - - $this->commands([ - GenerateFactoryCommand::class, - GeneratePermissionsCommand::class, - GenerateModelCommand::class, - GeneratePolicyCommand::class, - GenerateRequestCommand::class, - GenerateExportCommand::class, - GenerateControllerCommand::class, - GenerateRoutesCommand::class, - GenerateTestCommand::class, - GenerateViewsCommand::class, - GenerateApiControllerCommand::class, - GenerateApiTestCommand::class, - GenerateApiCommand::class, - GenerateAllCommand::class, - GenerateRollbackCommand::class, - GenerateAuthPasswordResetsCommand::class, - GenerateAuthFactoryCommand::class, - GenerateAuthConfigCommand::class, - GenerateAuthPermissionsCommand::class, - GenerateAuthModelCommand::class, - GenerateAuthPolicyCommand::class, - GenerateAuthRequestCommand::class, - GenerateAuthControllerCommand::class, - GenerateAuthConfirmPasswordControllerCommand::class, - GenerateAuthUpdatePasswordControllerCommand::class, - GenerateAuthResetPasswordControllerCommand::class, - GenerateAuthForgotPasswordControllerCommand::class, - GenerateAuthLoginControllerCommand::class, - GenerateAuthRegisterControllerCommand::class, - GenerateAuthVerificationControllerCommand::class, - GenerateAuthHomeControllerCommand::class, - ]); - } - - StubRenderer::loadStubsFrom(__DIR__ . '/../stubs', 'generators'); + $this->app->singleton(SchemaInspector::class, fn ($app) => new SchemaInspector($app->make(DatabaseManager::class), config('generators.connection'))); + $this->app->singleton(RelationResolver::class); } - /** - * Register the application services. - */ - public function register() + public function boot(): void { - // merge package config with user defined config - $this->mergeConfigFrom(__DIR__ . '/../config/config.php', 'generators'); + StubRenderer::loadStubsFrom(__DIR__ . '/../stubs', 'generators'); + + $this->loadTranslationsFrom(__DIR__ . '/../lang', 'generators'); - $this->app->bind(SchemaResolverInterface::class, function ($app, $parameters) { - $connection = config('database.default'); - $driver = config("database.connections.{$connection}.driver"); + if (! $this->app->runningInConsole()) { + return; + } - switch ($driver) { - case 'mysql': $class = SchemaResolverMySql::class; - break; + $this->publishes([ + __DIR__ . '/../config/generators.php' => config_path('generators.php'), + ], 'generators-config'); - default: throw new UnsupportedDbDriverException('This db driver is not supported: '.$driver); - } + $this->publishes([ + __DIR__ . '/../stubs' => base_path('stubs/vendor/generators'), + ], 'generators-stubs'); - return new $class(...array_values($parameters)); - }); + $this->commands(Commands::all()); } } diff --git a/src/IconProviders/BaseIconProvider.php b/src/IconProviders/BaseIconProvider.php deleted file mode 100644 index ff7e3d9..0000000 --- a/src/IconProviders/BaseIconProvider.php +++ /dev/null @@ -1,98 +0,0 @@ -findFromPresetIcons($word); - - if (! $icon) { - $icon = $this->findFromAllIcons($word); - } - - if (! $icon) { - $icon = $this->getDefaultIcon(); - } - - return $icon; - } - - /** - * Finds closest matching icon using Levenshtein distance - * https://stackoverflow.com/questions/14421303/php-nearest-string-comparison - * - * @param string $word - * @return string - */ - protected function findFromAllIcons(string $word): string - { - $word = Str::lower($word); - $icons = static::getIcons(); - - // no shortest distance found, yet - $shortest = 0; - - // loop through icons to find the closest - foreach ($icons as $icon) { - - // calculate the distance between the input icon, - // and the current icon - $lev = 0; - similar_text($word, $icon, $lev); - - // check for an exact match - if ($lev == 1) { - // closest icon is this one (exact match) - $closest = $icon; - $shortest = 1; - - // break out of the loop; we've found an exact match - break; - } - - // if this distance is less than the next found shortest - // distance, OR if a next shortest icon has not yet been found - if ($lev >= $shortest || $shortest == 0) { - // set the closest match, and shortest distance - $closest = $icon; - $shortest = $lev; - } - } - - //dd($shortest); - - return $closest ?? ''; - } - - protected function findFromPresetIcons(string $word): string - { - $word = StringCaser::pluralSnake($word); - $presets = static::getPresetIcons(); - - return $presets[$word] ?? ''; - } - - public function formatIcon(string $icon): string - { - return $this->getPrefix() . $icon; - } - - public static function getPresetIcons(): array - { - return static::$preset_icons; - } - - public static function getIcons(): array - { - return static::$icons; - } - - public abstract function getPrefix(): string; - - public abstract function getDefaultIcon(): string; -} diff --git a/src/IconProviders/FontAwesomeProvider.php b/src/IconProviders/FontAwesomeProvider.php deleted file mode 100644 index 3259f1d..0000000 --- a/src/IconProviders/FontAwesomeProvider.php +++ /dev/null @@ -1,128 +0,0 @@ - 'users', - 'posts' => 'file-alt', - 'comments' => 'comment', - 'categories' => 'folder', - 'tags' => 'tags', - 'roles' => 'user-shield', - 'permissions' => 'lock', - 'profiles' => 'id-badge', - 'likes' => 'thumbs-up', - 'followers' => 'user-friends', - 'favorites' => 'star', - 'messages' => 'envelope', - 'notifications' => 'bell', - 'tasks' => 'tasks', - 'projects' => 'project-diagram', - 'teams' => 'users-cog', - 'members' => 'user', - 'invoices' => 'file-invoice-dollar', - 'payments' => 'credit-card', - 'orders' => 'shopping-basket', - 'products' => 'shopping-cart', - 'customers' => 'user-tag', - 'suppliers' => 'truck', - 'contacts' => 'address-book', - 'addresses' => 'map-marker-alt', - 'countries' => 'globe', - 'cities' => 'city', - 'states' => 'map', - 'employees' => 'user-tie', - 'departments' => 'building', - 'positions' => 'briefcase', - 'assets' => 'boxes', - 'loans' => 'hand-holding-usd', - 'expenses' => 'money-bill-wave', - 'transactions' => 'exchange-alt', - 'subcategories' => 'folder-open', - 'services' => 'concierge-bell', - 'appointments' => 'calendar-check', - 'bookings' => 'ticket-alt', - 'events' => 'calendar-alt', - 'places' => 'map-pin', - 'reviews' => 'comments', - 'ratings' => 'star-half-alt', - 'images' => 'image', - 'videos' => 'video', - 'documents' => 'file-alt', - 'audios' => 'volume-up', - 'articles' => 'newspaper', - 'books' => 'book', - 'authors' => 'user-edit', - 'genres' => 'bookmark', - 'chapters' => 'book-open', - 'sections' => 'list-alt', - 'questions' => 'question-circle', - 'answers' => 'comment-dots', - 'exams' => 'file-signature', - 'results' => 'poll', - 'grades' => 'clipboard-check', - 'students' => 'user-graduate', - 'teachers' => 'chalkboard-teacher', - 'courses' => 'graduation-cap', - 'lessons' => 'file-alt', - 'assignments' => 'clipboard-list', - 'submissions' => 'file-upload', - 'quizzes' => 'question', - 'options' => 'list', - 'responses' => 'file-alt', - 'surveys' => 'poll-h', - 'polls' => 'poll', - 'votes' => 'vote-yea', - 'candidates' => 'user-tie', - 'parties' => 'users', - 'constituencies' => 'map-marker', - 'elections' => 'calendar-day', - 'winners' => 'trophy', - 'losers' => 'sad-tear', - 'contests' => 'flag-checkered', - 'entries' => 'pen', - 'judges' => 'gavel', - 'verdicts' => 'balance-scale', - 'claims' => 'file-contract', - 'policies' => 'file-alt', - 'contracts' => 'file-signature', - 'clauses' => 'file-alt', - 'warranties' => 'shield-alt', - 'agreements' => 'handshake', - 'proposals' => 'lightbulb', - 'bids' => 'file-invoice', - 'tenders' => 'file-contract', - 'awards' => 'award', - 'offers' => 'tags', - 'negotiations' => 'handshake', - 'deals' => 'hand-holding-usd', - ]; - - protected static array $icons = []; - - public static function getIcons(): array - { - if (! static::$icons) { - static::loadIcons(); - } - - return static::$icons; - } - - public static function loadIcons(): void - { - static::$icons = file(__DIR__ . '/resources/fontawesome-v7.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - } -} diff --git a/src/IconProviders/MaterialDesignIconicProvider.php b/src/IconProviders/MaterialDesignIconicProvider.php deleted file mode 100644 index 3469a12..0000000 --- a/src/IconProviders/MaterialDesignIconicProvider.php +++ /dev/null @@ -1,946 +0,0 @@ - 'accounts', - 'posts' => 'file-text', - 'comments' => 'comment', - 'categories' => 'folder', - 'tags' => 'label', - 'roles' => 'folder-person', - 'permissions' => 'lock', - 'profiles' => 'account-box', - 'likes' => 'thumb-up', - 'followers' => 'accounts-alt', - 'favorites' => 'star', - 'messages' => 'email', - 'notifications' => 'notifications', - 'tasks' => 'check-circle', - 'projects' => 'collection-text', - 'teams' => 'accounts-list', - 'members' => 'account-box', - 'invoices' => 'receipt', - 'payments' => 'money', - 'orders' => 'shopping-basket', - 'products' => 'shopping-cart', - 'customers' => 'account-box-mail', - 'suppliers' => 'truck', - 'contacts' => 'phone', - 'addresses' => 'pin', - 'countries' => 'globe-alt', - 'cities' => 'city-alt', - 'states' => 'landscape', - 'employees' => 'accounts-list', - 'departments' => 'case', - 'positions' => 'accounts-list-alt', - 'assets' => 'layers', - 'loans' => 'money-box', - 'expenses' => 'money-off', - 'transactions' => 'swap', - 'subcategories' => 'folder-outline', - 'services' => 'cutlery', - 'appointments' => 'calendar', - 'bookings' => 'ticket-star', - 'events' => 'calendar-alt', - 'places' => 'pin-drop', - 'reviews' => 'comment-alt', - 'ratings' => 'star-circle', - 'images' => 'image', - 'videos' => 'video', - 'documents' => 'file-text', - 'audios' => 'volume-up', - 'articles' => 'file-text', - 'books' => 'book', - 'authors' => 'account', - 'genres' => 'bookmark', - 'chapters' => 'collection-text', - 'sections' => 'tab', - 'questions' => 'help', - 'answers' => 'comment-text', - 'exams' => 'account-circle', - 'results' => 'star-circle', - 'grades' => 'label', - 'students' => 'face', - 'teachers' => 'face', - 'courses' => 'graduation-cap', - 'lessons' => 'file-text', - 'assignments' => 'assignment', - 'submissions' => 'file-plus', - 'quizzes' => 'help', - 'options' => 'view-list', - 'responses' => 'file-text', - 'surveys' => 'help', - 'polls' => 'help', - 'votes' => 'thumb-up', - 'candidates' => 'account-box', - 'parties' => 'group', - 'constituencies' => 'pin', - 'elections' => 'calendar-alt', - 'winners' => 'star', - 'losers' => 'star-circle', - 'contests' => 'toys', - 'entries' => 'border-color', - 'judges' => 'seat', - 'verdicts' => 'file-text', - 'claims' => 'file-text', - 'policies' => 'file-text', - 'contracts' => 'file-text', - 'clauses' => 'file-text', - 'warranties' => 'file-text', - 'agreements' => 'file-text', - 'proposals' => 'file-text', - 'bids' => 'file-text', - 'tenders' => 'file-text', - 'awards' => 'toys', - 'offers' => 'file-text', - 'negotiations' => 'file-text', - 'deals' => 'money', - ]; - - protected static array $icons = [ - '3d-rotation', - 'airplane-off', - 'airplane', - 'album', - 'archive', - 'assignment-account', - 'assignment-alert', - 'assignment-check', - 'assignment-o', - 'assignment-return', - 'assignment-returned', - 'assignment', - 'attachment-alt', - 'attachment', - 'audio', - 'badge-check', - 'balance-wallet', - 'balance', - 'battery-alert', - 'battery-flash', - 'battery-unknown', - 'battery', - 'bike', - 'block-alt', - 'block', - 'boat', - 'book-image', - 'book', - 'bookmark-outline', - 'bookmark', - 'brush', - 'bug', - 'bus', - 'cake', - 'car-taxi', - 'car-wash', - 'car', - 'card-giftcard', - 'card-membership', - 'card-travel', - 'card', - 'case-check', - 'case-download', - 'case-play', - 'case', - 'cast-connected', - 'cast', - 'chart-donut', - 'chart', - 'city-alt', - 'city', - 'close-circle-o', - 'close-circle', - 'close', - 'cocktail', - 'code-setting', - 'code-smartphone', - 'code', - 'coffee', - 'collection-bookmark', - 'collection-case-play', - 'collection-folder-image', - 'collection-image-o', - 'collection-image', - 'collection-item-1', - 'collection-item-2', - 'collection-item-3', - 'collection-item-4', - 'collection-item-5', - 'collection-item-6', - 'collection-item-7', - 'collection-item-8', - 'collection-item-9-plus', - 'collection-item-9', - 'collection-item', - 'collection-music', - 'collection-pdf', - 'collection-plus', - 'collection-speaker', - 'collection-text', - 'collection-video', - 'compass', - 'cutlery', - 'delete', - 'dialpad', - 'dns', - 'drink', - 'edit', - 'email-open', - 'email', - 'eye-off', - 'eye', - 'eyedropper', - 'favorite-outline', - 'favorite', - 'filter-list', - 'fire', - 'flag', - 'flare', - 'flash-auto', - 'flash-off', - 'flash', - 'flip', - 'flower-alt', - 'flower', - 'font', - 'fullscreen-alt', - 'fullscreen-exit', - 'fullscreen', - 'functions', - 'gas-station', - 'gesture', - 'globe-alt', - 'globe-lock', - 'globe', - 'graduation-cap', - 'group', - 'home', - 'hospital-alt', - 'hospital', - 'hotel', - 'hourglass-alt', - 'hourglass-outline', - 'hourglass', - 'http', - 'image-alt', - 'image-o', - 'image', - 'inbox', - 'invert-colors-off', - 'invert-colors', - 'key', - 'label-alt-outline', - 'label-alt', - 'label-heart', - 'label', - 'labels', - 'lamp', - 'landscape', - 'layers-off', - 'layers', - 'library', - 'link', - 'lock-open', - 'lock-outline', - 'lock', - 'mail-reply-all', - 'mail-reply', - 'mail-send', - 'mall', - 'map', - 'menu', - 'money-box', - 'money-off', - 'money', - 'more-vert', - 'more', - 'movie-alt', - 'movie', - 'nature-people', - 'nature', - 'navigation', - 'open-in-browser', - 'open-in-new', - 'palette', - 'parking', - 'pin-account', - 'pin-assistant', - 'pin-drop', - 'pin-help', - 'pin-off', - 'pin', - 'pizza', - 'plaster', - 'power-setting', - 'power', - 'print', - 'puzzle-piece', - 'quote', - 'railway', - 'receipt', - 'refresh-alt', - 'refresh-sync-alert', - 'refresh-sync-off', - 'refresh-sync', - 'refresh', - 'roller', - 'ruler', - 'scissors', - 'screen-rotation-lock', - 'screen-rotation', - 'search-for', - 'search-in-file', - 'search-in-page', - 'search-replace', - 'search', - 'seat', - 'settings-square', - 'settings', - 'shape', - 'shield-check', - 'shield-security', - 'shopping-basket', - 'shopping-cart-plus', - 'shopping-cart', - 'sign-in', - 'sort-amount-asc', - 'sort-amount-desc', - 'sort-asc', - 'sort-desc', - 'spellcheck', - 'spinner', - 'storage', - 'store-24', - 'store', - 'subway', - 'sun', - 'tab-unselected', - 'tab', - 'tag-close', - 'tag-more', - 'tag', - 'thumb-down', - 'thumb-up-down', - 'thumb-up', - 'ticket-star', - 'toll', - 'toys', - 'traffic', - 'translate', - 'triangle-down', - 'triangle-up', - 'truck', - 'turning-sign', - 'ungroup', - 'wallpaper', - 'washing-machine', - 'window-maximize', - 'window-minimize', - 'window-restore', - 'wrench', - 'zoom-in', - 'zoom-out', - 'alert-circle-o', - 'alert-circle', - 'alert-octagon', - 'alert-polygon', - 'alert-triangle', - 'help-outline', - 'help', - 'info-outline', - 'info', - 'notifications-active', - 'notifications-add', - 'notifications-none', - 'notifications-off', - 'notifications-paused', - 'notifications', - 'account-add', - 'account-box-mail', - 'account-box-o', - 'account-box-phone', - 'account-box', - 'account-calendar', - 'account-circle', - 'account-o', - 'account', - 'accounts-add', - 'accounts-alt', - 'accounts-list-alt', - 'accounts-list', - 'accounts-outline', - 'accounts', - 'face', - 'female', - 'male-alt', - 'male-female', - 'male', - 'mood-bad', - 'mood', - 'run', - 'walk', - 'cloud-box', - 'cloud-circle', - 'cloud-done', - 'cloud-download', - 'cloud-off', - 'cloud-outline-alt', - 'cloud-outline', - 'cloud-upload', - 'cloud', - 'download', - 'file-plus', - 'file-text', - 'file', - 'folder-outline', - 'folder-person', - 'folder-star-alt', - 'folder-star', - 'folder', - 'gif', - 'upload', - 'border-all', - 'border-bottom', - 'border-clear', - 'border-color', - 'border-horizontal', - 'border-inner', - 'border-left', - 'border-outer', - 'border-right', - 'border-style', - 'border-top', - 'border-vertical', - 'copy', - 'crop', - 'format-align-center', - 'format-align-justify', - 'format-align-left', - 'format-align-right', - 'format-bold', - 'format-clear-all', - 'format-clear', - 'format-color-fill', - 'format-color-reset', - 'format-color-text', - 'format-indent-decrease', - 'format-indent-increase', - 'format-italic', - 'format-line-spacing', - 'format-list-bulleted', - 'format-list-numbered', - 'format-ltr', - 'format-rtl', - 'format-size', - 'format-strikethrough-s', - 'format-strikethrough', - 'format-subject', - 'format-underlined', - 'format-valign-bottom', - 'format-valign-center', - 'format-valign-top', - 'redo', - 'select-all', - 'space-bar', - 'text-format', - 'transform', - 'undo', - 'wrap-text', - 'comment-alert', - 'comment-alt-text', - 'comment-alt', - 'comment-edit', - 'comment-image', - 'comment-list', - 'comment-more', - 'comment-outline', - 'comment-text-alt', - 'comment-text', - 'comment-video', - 'comment', - 'comments', - 'check-all', - 'check-circle-u', - 'check-circle', - 'check-square', - 'check', - 'circle-o', - 'circle', - 'dot-circle-alt', - 'dot-circle', - 'minus-circle-outline', - 'minus-circle', - 'minus-square', - 'minus', - 'plus-circle-o-duplicate', - 'plus-circle-o', - 'plus-circle', - 'plus-square', - 'plus', - 'square-o', - 'star-circle', - 'star-half', - 'star-outline', - 'star', - 'bluetooth-connected', - 'bluetooth-off', - 'bluetooth-search', - 'bluetooth-setting', - 'bluetooth', - 'camera-add', - 'camera-alt', - 'camera-bw', - 'camera-front', - 'camera-mic', - 'camera-party-mode', - 'camera-rear', - 'camera-roll', - 'camera-switch', - 'camera', - 'card-alert', - 'card-off', - 'card-sd', - 'card-sim', - 'desktop-mac', - 'desktop-windows', - 'device-hub', - 'devices-off', - 'devices', - 'dock', - 'floppy', - 'gamepad', - 'gps-dot', - 'gps-off', - 'gps', - 'headset-mic', - 'headset', - 'input-antenna', - 'input-composite', - 'input-hdmi', - 'input-power', - 'input-svideo', - 'keyboard-hide', - 'keyboard', - 'laptop-chromebook', - 'laptop-mac', - 'laptop', - 'mic-off', - 'mic-outline', - 'mic-setting', - 'mic', - 'mouse', - 'network-alert', - 'network-locked', - 'network-off', - 'network-outline', - 'network-setting', - 'network', - 'phone-bluetooth', - 'phone-end', - 'phone-forwarded', - 'phone-in-talk', - 'phone-locked', - 'phone-missed', - 'phone-msg', - 'phone-paused', - 'phone-ring', - 'phone-setting', - 'phone-sip', - 'phone', - 'portable-wifi-changes', - 'portable-wifi-off', - 'portable-wifi', - 'radio', - 'reader', - 'remote-control-alt', - 'remote-control', - 'router', - 'scanner', - 'smartphone-android', - 'smartphone-download', - 'smartphone-erase', - 'smartphone-info', - 'smartphone-iphone', - 'smartphone-landscape-lock', - 'smartphone-landscape', - 'smartphone-lock', - 'smartphone-portrait-lock', - 'smartphone-ring', - 'smartphone-setting', - 'smartphone-setup', - 'smartphone', - 'speaker', - 'tablet-android', - 'tablet-mac', - 'tablet', - 'tv-alt-play', - 'tv-list', - 'tv-play', - 'tv', - 'usb', - 'videocam-off', - 'videocam-switch', - 'videocam', - 'watch', - 'wifi-alt-2', - 'wifi-alt', - 'wifi-info', - 'wifi-lock', - 'wifi-off', - 'wifi-outline', - 'wifi', - 'arrow-left-bottom', - 'arrow-left', - 'arrow-merge', - 'arrow-missed', - 'arrow-right-top', - 'arrow-right', - 'arrow-split', - 'arrows', - 'caret-down-circle', - 'caret-down', - 'caret-left-circle', - 'caret-left', - 'caret-right-circle', - 'caret-right', - 'caret-up-circle', - 'caret-up', - 'chevron-down', - 'chevron-left', - 'chevron-right', - 'chevron-up', - 'forward', - 'long-arrow-down', - 'long-arrow-left', - 'long-arrow-return', - 'long-arrow-right', - 'long-arrow-tab', - 'long-arrow-up', - 'rotate-ccw', - 'rotate-cw', - 'rotate-left', - 'rotate-right', - 'square-down', - 'square-right', - 'swap-alt', - 'swap-vertical-circle', - 'swap-vertical', - 'swap', - 'trending-down', - 'trending-flat', - 'trending-up', - 'unfold-less', - 'unfold-more', - 'bike', - 'boat', - 'bus', - 'car', - 'railway', - 'run', - 'subway', - 'walk', - 'turning-sign', - 'layers-off', - 'layers', - 'ticket-star', - 'airplane', - 'money-box', - 'cocktail', - 'coffee', - 'car-wash', - 'store-24', - 'cutlery', - 'drink', - 'flower-alt', - 'gas-station', - 'shopping-cart', - 'hospital', - 'hotel', - 'washing-machine', - 'library', - 'mall', - 'movie-alt', - 'label', - 'parking', - 'hospital-alt', - 'phone', - 'pizza', - 'ticket-star', - 'email', - 'print', - 'camera', - 'truck', - 'store', - 'car-taxi', - 'male-female', - 'map', - 'gps-dot', - 'nature-people', - 'nature', - 'navigation', - 'pin-account', - 'pin-assistant', - 'pin-drop', - 'pin-help', - 'pin-off', - 'pin', - 'traffic', - 'apps', - 'grid-off', - 'grid', - 'view-agenda', - 'view-array', - 'view-carousel', - 'view-column', - 'view-comfy', - 'view-compact', - 'view-dashboard', - 'view-day', - 'view-headline', - 'view-list-alt', - 'view-list', - 'view-module', - 'view-quilt', - 'view-stream', - 'view-subtitles', - 'view-toc', - 'view-web', - 'view-week', - 'widgets', - 'alarm-check', - 'alarm-off', - 'alarm-plus', - 'alarm-snooze', - 'alarm', - 'calendar-alt', - 'calendar-check', - 'calendar-close', - 'calendar-note', - 'calendar', - 'time-countdown', - 'time-interval', - 'time-restore-setting', - 'time-restore', - 'time', - 'timer-off', - 'timer', - 'android-alt', - 'android', - 'apple', - 'behance', - 'codepen', - 'dribbble', - 'dropbox', - 'evernote', - 'facebook-box', - 'facebook', - 'github-box', - 'github', - 'google-drive', - 'google-earth', - 'google-glass', - 'google-maps', - 'google-pages', - 'google-play', - 'google-plus-box', - 'google-plus', - 'google', - 'instagram', - 'language-css3', - 'language-html5', - 'language-javascript', - 'language-python-alt', - 'language-python', - 'lastfm', - 'linkedin-box', - 'paypal', - 'pinterest-box', - 'pocket', - 'polymer', - 'rss', - 'share', - 'stackoverflow', - 'steam-square', - 'steam', - 'twitter-box', - 'twitter', - 'vk', - 'wikipedia', - 'windows', - '500px', - '8tracks', - 'amazon', - 'blogger', - 'delicious', - 'disqus', - 'flattr', - 'flickr', - 'github-alt', - 'google-old', - 'linkedin', - 'odnoklassniki', - 'outlook', - 'paypal-alt', - 'pinterest', - 'playstation', - 'reddit', - 'skype', - 'slideshare', - 'soundcloud', - 'tumblr', - 'twitch', - 'vimeo', - 'whatsapp', - 'xbox', - 'yahoo', - 'youtube-play', - 'youtube', - 'aspect-ratio-alt', - 'aspect-ratio', - 'blur-circular', - 'blur-linear', - 'blur-off', - 'blur', - 'brightness-2', - 'brightness-3', - 'brightness-4', - 'brightness-5', - 'brightness-6', - 'brightness-7', - 'brightness-auto', - 'brightness-setting', - 'broken-image', - 'center-focus-strong', - 'center-focus-weak', - 'compare', - 'crop-16-9', - 'crop-3-2', - 'crop-5-4', - 'crop-7-5', - 'crop-din', - 'crop-free', - 'crop-landscape', - 'crop-portrait', - 'crop-square', - 'exposure-alt', - 'exposure', - 'filter-b-and-w', - 'filter-center-focus', - 'filter-frames', - 'filter-tilt-shift', - 'gradient', - 'grain', - 'graphic-eq', - 'hdr-off', - 'hdr-strong', - 'hdr-weak', - 'hdr', - 'iridescent', - 'leak-off', - 'leak', - 'looks', - 'loupe', - 'panorama-horizontal', - 'panorama-vertical', - 'panorama-wide-angle', - 'photo-size-select-large', - 'photo-size-select-small', - 'picture-in-picture', - 'slideshow', - 'texture', - 'tonality', - 'vignette', - 'wb-auto', - 'eject-alt', - 'eject', - 'equalizer', - 'fast-forward', - 'fast-rewind', - 'forward-10', - 'forward-30', - 'forward-5', - 'hearing', - 'pause-circle-outline', - 'pause-circle', - 'pause', - 'play-circle-outline', - 'play-circle', - 'play', - 'playlist-audio', - 'playlist-plus', - 'repeat-one', - 'repeat', - 'replay-10', - 'replay-30', - 'replay-5', - 'replay', - 'shuffle', - 'skip-next', - 'skip-previous', - 'stop', - 'surround-sound', - 'tune', - 'volume-down', - 'volume-mute', - 'volume-off', - 'volume-up', - 'n-1-square', - 'n-2-square', - 'n-3-square', - 'n-4-square', - 'n-5-square', - 'n-6-square', - 'neg-1', - 'neg-2', - 'plus-1', - 'plus-2', - 'sec-10', - 'sec-3', - 'zero', - 'airline-seat-flat-angled', - 'airline-seat-flat', - 'airline-seat-individual-suite', - 'airline-seat-legroom-extra', - 'airline-seat-legroom-normal', - 'airline-seat-legroom-reduced', - 'airline-seat-recline-extra', - 'airline-seat-recline-normal', - 'airplay', - 'closed-caption', - 'confirmation-number', - 'developer-board', - 'disc-full', - 'explicit', - 'flight-land', - 'flight-takeoff', - 'flip-to-back', - 'flip-to-front', - 'group-work', - 'hd', - 'hq', - 'markunread-mailbox', - 'memory', - 'nfc', - 'play-for-work', - 'power-input', - 'present-to-all', - 'satellite', - 'tap-and-play', - 'vibration', - 'voicemail', - ]; -} diff --git a/src/IconProviders/resources/fontawesome-v7.txt b/src/IconProviders/resources/fontawesome-v7.txt deleted file mode 100644 index afe5711..0000000 --- a/src/IconProviders/resources/fontawesome-v7.txt +++ /dev/null @@ -1,4677 +0,0 @@ -0 -1 -2 -3 -4 -5 -6 -7 -8 -9 -exclamation -ditto -hashtag -dollar-sign -dollar -usd -percent -percentage -ampersand -apostrophe -bracket-round -parenthesis -bracket-round-right -asterisk -plus -add -comma -hyphen -period -slash-forward -colon -semicolon -less-than -equals -greater-than -question -at -a -b -c -d -e -f -g -h -i -j -k -l -m -n -o -p -q -r -s -t -u -v -w -x -y -z -bracket-square -bracket -bracket-left -slash-back -bracket-square-right -accent-grave -bracket-curly -bracket-curly-left -pipe -bracket-curly-right -tilde -caravan-simple -caravan-alt -cat-space -coffee-pot -comet -fan-table -faucet -faucet-drip -galaxy -garage -garage-car -garage-open -heat -house-chimney-window -house-day -house-person-leave -house-leave -house-person-depart -house-night -house-person-return -house-person-arrive -house-return -house-signal -lamp-desk -lamp-floor -light-ceiling -light-switch -light-switch-off -light-switch-on -microwave -outlet -oven -planet-moon -planet-ringed -police-box -person-to-portal -portal-enter -person-from-portal -portal-exit -radar -raygun -refrigerator -rocket-launch -sensor -sensor-triangle-exclamation -sensor-alert -sensor-fire -sensor-on -sensor-cloud -sensor-smoke -siren -siren-on -solar-system -circle-sort -sort-circle -circle-sort-down -sort-circle-down -circle-sort-up -sort-circle-up -space-station-moon -space-station-moon-construction -space-station-moon-alt -sprinkler -star-shooting -starfighter -starfighter-twin-ion-engine -starfighter-alt -starship -starship-freighter -sword-laser -sword-laser-alt -swords-laser -telescope -temperature-arrow-down -temperature-down -temperature-arrow-up -temperature-up -trailer -transporter -transporter-1 -transporter-2 -transporter-3 -transporter-empty -ufo -ufo-beam -user-alien -user-robot -user-visor -vacuum -vacuum-robot -window-frame -window-frame-open -coffin-cross -folder-arrow-down -folder-download -folder-arrow-up -folder-upload -user-unlock -bacteria -bacterium -box-tissue -hand-holding-medical -hand-sparkles -hands-bubbles -hands-wash -handshake-slash -handshake-alt-slash -handshake-simple-slash -head-side-cough -head-side-cough-slash -head-side-mask -head-side-virus -house-chimney-user -house-laptop -laptop-house -lungs-virus -people-arrows -people-arrows-left-right -plane-slash -pump-medical -pump-soap -shield-virus -sink -soap -stopwatch-20 -shop-slash -store-alt-slash -store-slash -toilet-paper-slash -users-slash -virus -virus-slash -viruses -vest -vest-patches -airplay -alt -angle -angle-90 -apple-core -arrow-down-from-dotted-line -arrow-down-left -arrow-down-left-and-arrow-up-right-to-center -arrow-down-right -arrow-down-to-bracket -arrow-down-to-dotted-line -arrow-down-to-square -arrow-trend-down -arrow-trend-up -arrow-up-arrow-down -sort-up-down -arrow-up-from-bracket -arrow-up-from-dotted-line -arrow-up-from-square -arrow-up-left -arrow-up-left-from-circle -arrow-up-right -arrow-up-right-and-arrow-down-left-from-center -arrow-up-to-dotted-line -arrows-cross -arrows-from-dotted-line -arrows-from-line -arrows-minimize -compress-arrows -arrows-to-dotted-line -arrows-to-line -audio-description-slash -austral-sign -avocado -award-simple -baht-sign -bars-filter -bars-sort -basket-shopping-simple -shopping-basket-alt -battery-exclamation -battery-low -battery-1 -bee -beer-mug -beer-foam -bitcoin-sign -block-quote -bolt-auto -bolt-lightning -bolt-slash -book-arrow-right -book-arrow-up -book-bookmark -book-circle-arrow-right -book-circle-arrow-up -book-copy -book-font -book-open-cover -book-open-alt -book-section -book-law -bookmark-slash -bowling-ball-pin -box-circle-check -brackets-round -parentheses -brain-circuit -brake-warning -briefcase-blank -brightness -brightness-low -browsers -buildings -burger-fries -burger-glass -calendar-arrow-down -calendar-download -calendar-arrow-up -calendar-upload -calendar-clock -calendar-time -calendar-heart -calendar-image -calendar-lines -calendar-note -calendar-range -calendars -camera-rotate -camera-slash -camera-viewfinder -screenshot -cart-minus -cart-shopping-fast -cart-xmark -castle -cedi-sign -chart-bullet -chart-candlestick -chart-column -chart-gantt -chart-line-up -chart-pyramid -chart-radar -chart-scatter-3d -chart-scatter-bubble -chart-tree-map -chart-waterfall -cherries -circle-0 -circle-1 -circle-2 -circle-3 -circle-4 -circle-5 -circle-6 -circle-7 -circle-8 -circle-9 -circle-a -circle-ampersand -circle-arrow-down-left -circle-arrow-down-right -circle-arrow-up-left -circle-arrow-up-right -circle-b -circle-bolt -circle-book-open -book-circle -circle-bookmark -bookmark-circle -circle-c -circle-calendar -calendar-circle -circle-camera -camera-circle -circle-d -circle-dashed -circle-divide -circle-down-left -circle-down-right -circle-e -circle-ellipsis -circle-ellipsis-vertical -circle-envelope -envelope-circle -circle-exclamation-check -circle-f -circle-g -circle-half -circle-i -circle-j -circle-k -circle-l -circle-m -circle-microphone -microphone-circle -circle-microphone-lines -microphone-circle-alt -circle-n -circle-o -circle-p -circle-phone -phone-circle -circle-phone-flip -phone-circle-alt -circle-phone-hangup -phone-circle-down -circle-q -circle-quarter -circle-r -circle-s -circle-small -circle-star -star-circle -circle-t -circle-three-quarters -circle-trash -trash-circle -circle-u -circle-up-left -circle-up-right -circle-v -circle-video -video-circle -circle-w -circle-waveform-lines -waveform-circle -circle-x -circle-y -circle-z -clapperboard -clapperboard-play -clipboard-medical -clock-desk -closed-captioning-slash -clothes-hanger -cloud-slash -cloud-word -clover -code-compare -code-fork -code-pull-request -code-simple -coffee-bean -coffee-beans -colon-sign -command -comment-arrow-down -comment-arrow-up -comment-arrow-up-right -comment-captions -comment-code -comment-image -comment-middle -comment-middle-top -comment-question -comment-quote -comment-text -comments-question -comments-question-check -conveyor-belt-empty -crate-empty -cruzeiro-sign -delete-right -desktop-arrow-down -diagram-lean-canvas -diagram-nested -diagram-sankey -diagram-venn -dial -dial-med-high -dial-high -dial-low -dial-max -dial-med -dial-med-low -dial-min -dial-off -display -display-arrow-down -display-code -desktop-code -display-medical -desktop-medical -dolphin -dong-sign -down-left -down-right -eggplant -elevator -engine -envelope-dot -envelope-badge -envelopes -escalator -eye-dropper-full -eye-dropper-half -ferris-wheel -file-binary -file-heart -file-plus-minus -files -film-slash -films -filter-circle-xmark -filter-list -filter-slash -filters -fire-hydrant -floppy-disk-circle-arrow-right -save-circle-arrow-right -floppy-disk-circle-xmark -floppy-disk-times -save-circle-xmark -save-times -floppy-disk-pen -floppy-disks -florin-sign -folder-closed -folder-bookmark -folder-gear -folder-cog -folder-grid -folder-heart -folder-image -folder-magnifying-glass -folder-search -folder-medical -folder-music -folder-user -franc-sign -gif -glass-empty -glass-half -glass-half-empty -glass-half-full -grate -grate-droplet -grid -grid-3 -grid-2 -grid-2-plus -grid-4 -grid-5 -guarani-sign -gun -gun-slash -gun-squirt -hand-back-point-down -hand-back-point-left -hand-back-point-ribbon -hand-back-point-right -hand-back-point-up -hand-fingers-crossed -hand-holding-skull -hand-love -hand-point-ribbon -hand-wave -hands-clapping -hand-horns -head-side-heart -heart-half -heart-half-stroke -heart-half-alt -hexagon-divide -high-definition -rectangle-hd -highlighter-line -house-user -home-user -house-building -house-chimney-heart -house-tree -house-turret -image-landscape -landscape -image-polaroid-user -image-slash -image-user -images-user -inbox-full -inboxes -indian-rupee-sign -indian-rupee -inr -input-numeric -input-pipe -input-text -keyboard-brightness -keyboard-brightness-low -keyboard-down -keyboard-left -kip-sign -lamp-street -laptop-arrow-down -laptop-slash -lari-sign -lasso-sparkles -lightbulb-exclamation-on -link-horizontal -chain-horizontal -link-horizontal-slash -chain-horizontal-slash -link-simple -link-simple-slash -list-dropdown -list-radio -list-timeline -list-tree -litecoin-sign -loader -manat-sign -manhole -mask-face -memo -memo-circle-check -memo-pad -message-arrow-down -comment-alt-arrow-down -message-arrow-up -comment-alt-arrow-up -message-arrow-up-right -message-captions -comment-alt-captions -message-code -message-image -comment-alt-image -message-middle -comment-middle-alt -message-middle-top -comment-middle-top-alt -message-question -message-quote -comment-alt-quote -message-sms -message-text -comment-alt-text -messages-question -meter -meter-bolt -meter-droplet -meter-fire -microchip-ai -mill-sign -mobile-notch -mobile-iphone -mobile-signal -mobile-signal-out -money-bill-simple -money-bill-simple-wave -money-bills -money-bills-simple -money-bills-alt -mug-tea-saucer -naira-sign -nfc -nfc-lock -nfc-magnifying-glass -nfc-pen -nfc-signal -nfc-slash -nfc-trash -notdef -note -note-medical -notebook -notes -octagon-divide -octagon-exclamation -oil-can-drip -paintbrush-pencil -pallet-box -panorama -paper-plane-top -paper-plane-alt -send -peach -pear -pedestal -pen-circle -pen-clip-slash -pen-alt-slash -pen-fancy-slash -pen-field -pen-line -pen-slash -pen-swirl -pencil-slash -people -people-dress -people-dress-simple -people-pants -people-pants-simple -people-simple -person-dress-simple -person-pinball -person-seat -person-seat-reclined -person-simple -peseta-sign -peso-sign -phone-arrow-down-left -phone-arrow-down -phone-incoming -phone-arrow-up-right -phone-arrow-up -phone-outgoing -phone-hangup -phone-missed -phone-xmark -photo-film-music -pinball -plane-prop -plane-tail -plane-up -plane-up-slash -play-pause -puzzle-piece-simple -puzzle-piece-alt -quotes -rectangle-pro -pro -rectangle-terminal -rectangle-vertical-history -reel -reply-clock -reply-time -restroom-simple -rhombus -rotate-exclamation -rupiah-sign -screencast -scribble -sd-cards -seal -seal-exclamation -seal-question -seat-airline -shelves-empty -shield-exclamation -shield-keyhole -shield-minus -shield-plus -shield-slash -shield-xmark -shield-times -shower-down -shower-alt -sidebar -sidebar-flip -signal-stream-slash -sim-cards -slider -sliders-simple -split -square-0 -square-1 -square-2 -square-3 -square-4 -square-5 -square-6 -square-7 -square-8 -square-9 -square-a -square-ampersand -square-arrow-down-left -square-arrow-down-right -square-arrow-up-left -square-b -square-bolt -square-c -square-code -square-d -square-dashed -square-divide -square-down-left -square-down-right -square-e -square-ellipsis -square-ellipsis-vertical -square-f -square-g -square-i -square-j -square-k -square-l -square-m -square-n -square-o -square-p -square-phone-hangup -phone-square-down -square-q -square-r -square-s -square-small -square-star -square-t -square-u -square-up-left -square-user -square-v -square-w -square-x -square-y -square-z -stairs -standard-definition -rectangle-sd -star-sharp -star-sharp-half -star-sharp-half-stroke -star-sharp-half-alt -starfighter-twin-ion-engine-advanced -starfighter-alt-advanced -sun-bright -sun-alt -table-layout -table-pivot -table-rows -rows -table-tree -tally-1 -tally-2 -tally-3 -tally-4 -taxi-bus -temperature-list -ticket-airline -ticket-perforated-plane -ticket-plane -tickets-airline -tickets-perforated-plane -tickets-plane -timeline -timeline-arrow -timer -toilet-paper-under -toilet-paper-blank-under -toilet-paper-reverse -toilet-paper-reverse-alt -toilet-paper-under-slash -toilet-paper-reverse-slash -tower-control -train-subway-tunnel -subway-tunnel -transformer-bolt -transporter-4 -transporter-5 -transporter-6 -transporter-7 -trash-can-check -trash-can-clock -trash-can-list -trash-can-plus -trash-can-slash -trash-alt-slash -trash-can-xmark -trash-check -trash-clock -trash-list -trash-plus -trash-slash -trash-xmark -truck-container-empty -truck-flatbed -truck-front -truck-tow -tty-answer -teletype-answer -tugrik-sign -turkish-lira-sign -try -turkish-lira -umbrella-simple -umbrella-alt -up-left -up-right -user-bounty-hunter -user-pilot -user-pilot-tie -user-shakespeare -utility-pole -utility-pole-double -vault -video-arrow-down-left -video-arrow-up-right -wand-magic-sparkles -magic-wand-sparkles -watch-apple -watch-smart -wheat-awn -wheat-alt -wheelchair-move -wheelchair-alt -wifi-exclamation -wrench-simple -robot-astromech -360-degrees -aperture -arrow-turn-down-left -balloon -balloons -banana -bangladeshi-taka-sign -bench-tree -blueberries -bowl-chopsticks -bowl-chopsticks-noodles -bowl-rice -briefcase-arrow-right -citrus -citrus-slice -coconut -display-slash -desktop-slash -face-explode -exploding-head -face-viewfinder -family -family-dress -family-pants -fence -fish-bones -grapes -kiwi-fruit -mango -melon -melon-slice -money-from-bracket -money-simple-from-bracket -olive -olive-branch -option -party-bell -party-horn -peapod -person-pregnant -pineapple -rectangle-code -rectangles-mixed -roller-coaster -square-quote -square-terminal -strawberry -table-picnic -thought-bubble -tick -tomato -turn-down-left -user-police -user-police-tie -watermelon-slice -wheat-awn-slash -wheat-slash -badminton -binary -binary-circle-check -binary-lock -binary-slash -boot-heeled -car-bolt -car-circle-bolt -car-mirrors -car-side-bolt -clock-eight -clock-eight-thirty -clock-eleven -clock-eleven-thirty -clock-five -clock-five-thirty -clock-four-thirty -clock-nine -clock-nine-thirty -clock-one -clock-one-thirty -clock-seven -clock-seven-thirty -clock-six -clock-six-thirty -clock-ten -clock-ten-thirty -clock-three -clock-three-thirty -clock-twelve -clock-twelve-thirty -clock-two -clock-two-thirty -cloud-check -cloud-minus -cloud-plus -cloud-xmark -columns-3 -crystal-ball -cup-straw -cup-straw-swoosh -distribute-spacing-horizontal -distribute-spacing-vertical -eyes -face-angry-horns -face-anguished -face-anxious-sweat -face-astonished -face-confounded -face-confused -face-cowboy-hat -face-disappointed -face-disguise -face-downcast-sweat -face-drooling -face-expressionless -face-eyes-xmarks -face-fearful -face-frown-slight -face-glasses -face-hand-over-mouth -face-hand-yawn -face-head-bandage -face-hushed -face-icicles -face-kiss-closed-eyes -face-lying -face-mask -face-monocle -face-nauseated -face-nose-steam -face-party -face-pensive -face-persevering -face-pleading -face-pouting -face-raised-eyebrow -face-relieved -face-sad-sweat -face-scream -face-shush -face-sleeping -face-sleepy -face-smile-halo -face-smile-hearts -face-smile-horns -face-smile-relaxed -face-smile-tear -face-smile-tongue -face-smile-upside-down -face-smiling-hands -face-smirking -face-sunglasses -face-swear -face-thermometer -face-thinking -face-tissue -face-tongue-money -face-tongue-sweat -face-unamused -face-vomit -face-weary -face-woozy -face-worried -face-zany -face-zipper -file-lock -file-slash -fishing-rod -flying-disc -gallery-thumbnails -goal-net -golf-flag-hole -grid-dividers -hockey-stick-puck -house-chimney -home-lg -house-chimney-blank -house-crack -house-medical -house-window -key-skeleton-left-right -lacrosse-stick -lacrosse-stick-ball -mask-snorkel -message-bot -moped -nesting-dolls -objects-align-bottom -objects-align-center-horizontal -objects-align-center-vertical -objects-align-left -objects-align-right -objects-align-top -objects-column -paperclip-vertical -pinata -pipe-smoking -pool-8-ball -rugby-ball -shirt-long-sleeve -shirt-running -shirt-tank-top -signature-lock -signature-slash -ski-boot -ski-boot-ski -slot-machine -teddy-bear -truck-bolt -uniform-martial-arts -user-chef -user-hair-buns -arrow-left-long-to-line -arrow-right-long-to-line -arrow-turn-down-right -bagel -baguette -blanket-fire -block-brick -wall-brick -block-brick-fire -firewall -block-question -bowl-scoop -bowl-shaved-ice -bowl-scoops -bowl-spoon -bread-slice-butter -broccoli -burger-lettuce -butter -cake-slice -shortcake -can-food -candy -candy-bar -chocolate-bar -card-club -card-diamond -card-heart -card-spade -cards -cart-arrow-up -cart-circle-arrow-down -cart-circle-arrow-up -cart-circle-check -cart-circle-exclamation -cart-circle-plus -cart-circle-xmark -cent-sign -chestnut -chopsticks -circle-quarters -code-pull-request-closed -code-pull-request-draft -coin-blank -coin-front -coin-vertical -corner -crab -soft-serve -creemee -cucumber -cupcake -custard -dash -minus-large -diamond-exclamation -donut -doughnut -down-from-dotted-line -down-to-dotted-line -face-awesome -gave-dandy -falafel -flatbread -flatbread-stuffed -fondue-pot -garlic -grip-dots -grip-dots-vertical -h5 -h6 -hammer-crash -hashtag-lock -hexagon-check -hexagon-exclamation -honey-pot -hose -hose-reel -hourglass-clock -hundred-points -100 -leafy-green -left-long-to-line -light-emergency -light-emergency-on -lobster -lock-a -lock-hashtag -lollipop -lollypop -mushroom -octagon-check -onion -page -page-caret-down -file-caret-down -page-caret-up -file-caret-up -pan-food -pan-frying -pancakes -panel-ews -panel-fire -peanut -peanuts -pepper -person-to-door -phone-intercom -pickleball -pipe-circle-check -pipe-collar -pipe-section -pipe-valve -plate-utensils -plus-minus -pompebled -popsicle -pot-food -potato -pretzel -pump -puzzle -right-long-to-line -sailboat -salt-shaker -section -shrimp -shutters -sportsball -sprinkler-ceiling -square-a-lock -square-quarters -square-ring -squid -tamale -tank-water -train-track -train-tunnel -turn-down-right -up-from-dotted-line -up-to-dotted-line -user-doctor-hair -user-doctor-hair-long -user-hair -user-hair-long -user-hair-mullet -business-front -party-back -trian-balbot -user-nurse-hair -user-nurse-hair-long -user-tie-hair -user-tie-hair-long -user-vneck -user-vneck-hair -user-vneck-hair-long -utensils-slash -vent-damper -waffle -00 -apartment -bird -block -bowl-soft-serve -brazilian-real-sign -cabin -calendar-circle-exclamation -calendar-circle-minus -calendar-circle-plus -calendar-circle-user -calendar-lines-pen -chart-simple -chart-simple-horizontal -diagram-cells -diagram-next -diagram-predecessor -diagram-previous -diagram-subtask -diagram-successor -earth-oceania -globe-oceania -face-beam-hand-over-mouth -face-clouds -face-diagonal-mouth -face-dotted -face-exhaling -face-hand-peeking -face-holding-back-tears -face-melting -face-saluting -face-spiral-eyes -fort -house-blank -home-blank -square-kanban -square-list -sushi -nigiri -sushi-roll -maki-roll -makizushi -album-circle-plus -album-circle-user -album-collection-circle-plus -album-collection-circle-user -bug-slash -cloud-exclamation -cloud-question -file-circle-info -file-circle-plus -frame -gauge-circle-bolt -gauge-circle-minus -gauge-circle-plus -memo-circle-info -object-exclude -object-intersect -object-subtract -object-union -pen-nib-slash -rectangle-history -rectangle-history-circle-plus -rectangle-history-circle-user -shop-lock -store-lock -user-robot-xmarks -virus-covid -virus-covid-slash -anchor-circle-check -anchor-circle-exclamation -anchor-circle-xmark -anchor-lock -arrow-down-to-arc -arrow-down-up-across-line -arrow-down-up-lock -arrow-right-from-arc -arrow-right-to-arc -arrow-right-to-city -arrow-up-from-arc -arrow-up-from-ground-water -arrow-up-from-water-pump -arrow-up-right-dots -arrows-down-to-line -arrows-down-to-people -arrows-left-right-to-line -arrows-spin -arrows-split-up-and-left -arrows-to-circle -arrows-to-dot -arrows-to-eye -arrows-turn-right -arrows-turn-to-dots -arrows-up-to-line -bore-hole -bottle-droplet -bottle-water -bowl-food -boxes-packing -bridge -bridge-circle-check -bridge-circle-exclamation -bridge-circle-xmark -bridge-lock -bridge-suspension -bridge-water -bucket -bugs -building-circle-arrow-right -building-circle-check -building-circle-exclamation -building-circle-xmark -building-flag -building-lock -building-ngo -building-shield -building-un -building-user -building-wheat -burst -car-on -car-tunnel -cards-blank -child-combatant -child-rifle -children -circle-nodes -clipboard-question -cloud-showers-water -computer -cubes-stacked -down-to-bracket -envelope-circle-check -explosion -ferry -file-circle-exclamation -file-circle-minus -file-circle-question -file-shield -fire-burner -fish-fins -flask-vial -glass-water -glass-water-droplet -group-arrows-rotate -hand-holding-hand -handcuffs -hands-bound -hands-holding-child -hands-holding-circle -heart-circle-bolt -heart-circle-check -heart-circle-exclamation -heart-circle-minus -heart-circle-plus -heart-circle-xmark -helicopter-symbol -helmet-un -hexagon-image -hexagon-vertical-nft -hexagon-vertical-nft-slanted -hill-avalanche -hill-rockslide -house-circle-check -house-circle-exclamation -house-circle-xmark -house-fire -house-flag -house-flood-water -house-flood-water-circle-arrow-right -house-lock -house-medical-circle-check -house-medical-circle-exclamation -house-medical-circle-xmark -house-medical-flag -house-tsunami -jar -jar-wheat -jet-fighter-up -jug-detergent -kitchen-set -land-mine-on -landmark-flag -laptop-file -lines-leaning -location-pin-lock -locust -magnifying-glass-arrow-right -magnifying-glass-chart -mars-and-venus-burst -mask-ventilator -mattress-pillow -merge -mobile-retro -money-bill-transfer -money-bill-trend-up -money-bill-wheat -mosquito -mosquito-net -mound -mountain-city -mountain-sun -nfc-symbol -oil-well -people-group -people-line -people-pulling -people-robbery -people-roof -person-arrow-down-to-line -person-arrow-up-from-line -person-breastfeeding -person-burst -person-cane -person-chalkboard -person-circle-check -person-circle-exclamation -person-circle-minus -person-circle-plus -person-circle-question -person-circle-xmark -person-dress-burst -person-drowning -person-falling -person-falling-burst -person-half-dress -person-harassing -person-military-pointing -person-military-rifle -person-military-to-person -person-rays -person-rifle -person-shelter -person-walking-arrow-loop-left -person-walking-arrow-right -person-walking-dashed-line-arrow-right -person-walking-luggage -plane-circle-check -plane-circle-exclamation -plane-circle-xmark -plane-lock -plate-wheat -plug-circle-bolt -plug-circle-check -plug-circle-exclamation -plug-circle-minus -plug-circle-plus -plug-circle-xmark -ranking-star -road-barrier -road-bridge -road-circle-check -road-circle-exclamation -road-circle-xmark -road-lock -road-spikes -rug -sack-xmark -school-circle-check -school-circle-exclamation -school-circle-xmark -school-flag -school-lock -sheet-plastic -shield-cat -shield-dog -shield-heart -shield-quartered -square-nfi -square-person-confined -square-virus -staff-snake -rod-asclepius -rod-snake -staff-aesculapius -sun-plant-wilt -tarp -tarp-droplet -tent -tent-arrow-down-to-line -tent-arrow-left-right -tent-arrow-turn-left -tent-arrows-down -tents -toilet-portable -toilets-portable -tower-cell -tower-observation -tree-city -trillium -trowel -trowel-bricks -truck-arrow-right -truck-droplet -truck-field -truck-field-un -truck-plane -up-from-bracket -users-between-lines -users-line -users-rays -users-rectangle -users-viewfinder -vial-circle-check -vial-virus -wheat-awn-circle-exclamation -worm -xmarks-lines -xmark-large -child-dress -child-reaching -plus-large -crosshairs-simple -file-circle-check -file-circle-xmark -gamepad-modern -gamepad-alt -grill -grill-fire -grill-hot -lightbulb-cfl -lightbulb-cfl-on -mouse-field -person-through-window -plant-wilt -ring-diamond -stapler -toggle-large-off -toggle-large-on -toilet-paper-check -toilet-paper-xmark -train-tram -buoy -buoy-mooring -diamond-half -diamond-half-stroke -game-console-handheld-crank -interrobang -mailbox-flag-up -mustache -nose -phone-arrow-right -pickaxe -prescription-bottle-pill -snowflake-droplets -square-dashed-circle-plus -tricycle -tricycle-adult -user-magnifying-glass -comment-heart -message-heart -pencil-mechanical -skeleton-ribs -billboard -circle-euro -circle-sterling -circle-yen -broom-wide -wreath-laurel -circle-quarter-stroke -circle-three-quarters-stroke -webhook -sparkle -chart-line-up-down -chart-mixed-up-circle-currency -chart-mixed-up-circle-dollar -grid-round -grid-round-2 -grid-round-2-plus -grid-round-4 -grid-round-5 -arrow-progress -right-left-large -calendar-users -display-chart-up -display-chart-up-circle-currency -display-chart-up-circle-dollar -laptop-binary -gear-code -gear-complex -gear-complex-code -file-doc -file-zip -flask-gear -bag-seedling -bin-bottles -bin-bottles-recycle -bin-recycle -conveyor-belt-arm -jug-bottle -lightbulb-gear -dinosaur -person-running-fast -circles-overlap -cloud-binary -chf-sign -user-group-simple -chart-pie-simple-circle-currency -chart-pie-simple-circle-dollar -hat-beach -person-dress-fairy -person-fairy -swap -swap-arrows -angles-up-down -globe-pointer -subtitles -subtitles-slash -head-side-gear -lighthouse -raccoon -arrow-down-from-arc -arrow-left-from-arc -arrow-left-to-arc -arrow-up-to-arc -building-magnifying-glass -building-memo -hammer-brush -hand-holding-circle-dollar -landmark-magnifying-glass -sign-post -sign-posts -sign-posts-wrench -tent-double-peak -truck-utensils -t-rex -spinner-scale -bell-ring -arrows-rotate-reverse -rotate-reverse -arrow-turn-left -arrow-turn-left-down -arrow-turn-left-up -arrow-turn-right -turn-left -turn-left-down -turn-left-up -turn-right -location-arrow-up -ticket-perforated -tickets-perforated -cannon -court-sport -file-eps -file-gif -file-jpg -file-mov -file-mp3 -file-mp4 -file-ppt -file-svg -file-vector -file-xls -folder-check -chart-kanban -bag-shopping-minus -bag-shopping-plus -basket-shopping-minus -basket-shopping-plus -file-xml -bulldozer -excavator -truck-ladder -tickets -tickets-simple -truck-fire -wave -waves-sine -magnifying-glass-arrows-rotate -magnifying-glass-music -magnifying-glass-play -magnifying-glass-waveform -music-magnifying-glass -reflect-horizontal -reflect-vertical -file-png -arrow-down-from-bracket -arrow-left-from-bracket -arrow-left-to-bracket -arrow-up-to-bracket -down-from-bracket -left-from-bracket -left-to-bracket -up-to-bracket -reflect-both -file-cad -bottle-baby -table-cells-column-lock -table-cells-lock -table-cells-row-lock -circle-wifi -circle-wifi-circle-wifi -circle-wifi-group -circle-gf -ant -caduceus -web-awesome -globe-wifi -hydra -lightbulb-message -octopus -user-beard-bolt -user-hoodie -diamonds-4 -thumbtack-slash -thumb-tack-slash -table-cells-column-unlock -table-cells-row-unlock -table-cells-unlock -chart-diagram -comment-nodes -file-fragment -file-half-dashed -hexagon-nodes -hexagon-nodes-bolt -square-binary -carpool -car-people -chart-sine -chart-fft -circles-overlap-3 -pronoun -bar-progress -bar-progress-empty -bar-progress-full -bar-progress-half -bar-progress-quarter -bar-progress-three-quarters -grid-2-minus -grid-round-2-minus -table-cells-columns -table-cells-header -table-cells-header-lock -table-cells-header-unlock -table-cells-rows -circle-equals -hexagon-equals -octagon-equals -rectangle-minus -rectangle-plus -square-equals -arrow-down-long-to-line -arrow-left-arrow-right -arrow-left-from-dotted-line -arrow-left-to-dotted-line -arrow-right-from-dotted-line -arrow-right-to-dotted-line -arrow-up-long-to-line -direction-left-right -direction-up-down -down-long-to-line -down-up -left-from-dotted-line -left-to-dotted-line -right-from-dotted-line -right-to-dotted-line -up-long-to-line -barn -circle-house -garage-empty -house-unlock -school-unlock -stadium -tent-circus -ball-yarn -bra -briefs -dress -jeans -jeans-straight -panties -pants -pants-straight -shirt-jersey -shoe -shorts -sneaker -circle-share-nodes -comment-dot -comment-waveform -envelope-circle-user -message-dot -message-waveform -phone-connection -phone-waveform -postage-stamp -circle-florin -circle-ruble -square-chf -square-lira -norwegian-krone-sign -circle-renminbi -square-peseta -circle-brazilian-real -circle-won -square-cruzeiro -circle-currency -circle-hryvnia -square-cent -square-brazilian-real -square-bitcoin -circle-peruvian-soles -circle-litecoin -square-indian-rupee -circle-lira -square-litecoin -square-ruble -circle-malaysian-ringgit -malaysian-ringgit-sign -circle-manat -circle-colon -circle-kip -australian-dollar-sign -circle-peso -circle-polish-zloty -circle-bangladeshi-taka -circle-mill -circle-shekel -square-manat -peruvian-soles-sign -circle-rupiah -square-norwegian-krone -square-naira -square-won -square-mill -polish-zloty-sign -square-currency -square-kip -square-guarani -square-dong -square-hryvnia -circle-tugrik -square-rupiah -square-sterling -circle-rupee -square-rupee -square-peruvian-soles -square-florin -square-australian-dollar -square-baht -square-peso -circle-austral -square-swedish-krona -circle-lari -circleapore-dollar -square-turkish-lira -danish-krone-sign -circle-franc -circle-cruzeiro -circle-dong -square-yen -circle-tenge -square-austral -square-eurozone -square-tugrik -square-cedi -circle-cent -currency-sign -circle-chf -circle-baht -signapore-dollar-sign -square-franc -circle-australian-dollar -square-tenge -square-euro -squareapore-dollar -circle-indian-rupee -square-shekel -square-polish-zloty -circle-bitcoin -circle-norwegian-krone -circle-turkish-lira -square-colon -circle-guarani -renminbi-sign -square-renminbi -swedish-krona-sign -square-lari -eurozone-sign -circle-peseta -circle-cedi -circle-swedish-krona -square-bangladeshi-taka -circle-eurozone -circle-danish-krone -square-danish-krone -square-malaysian-ringgit -circle-naira -mobile-arrow-down -clone-plus -paintbrush-fine-slash -paintbrush-slash -pencil-line -slider-circle -thumbtack-angle -thumbtack-angle-slash -book-open-lines -book-spine -bookmark-plus -clipboard-clock -clipboard-exclamation -file-ban -notes-sticky -capsule -ear-circle-checkmark -ear-triangle-exclamation -ear-waveform -head-side-circuit -head-side-speak -microphone-signal-meter -spine -vial-vertical -bin -seat -seats -camera-circle-ellipsis -camera-clock -camera-shutter -film-music -film-stack -image-circle-arrow-down -image-circle-check -image-circle-plus -image-circle-xmark -image-music -image-stack -rectangle-4k -rectangle-high-dynamic-range -rectangle-video-on-demand -user-viewfinder -video-down-to-line -video-question -gas-pump-left -gas-pump-right -location-arrow-slash -airplay-audio -headphones-slash -microphone-circle-plus -microphone-circle-xmark -open-captioning -play-flip -square-microphone -trombone -person-arms-raised -person-basketball -person-carry-empty -person-golfing -person-limbs-wide -person-seat-window -person-soccer -person-swimming-pool -person-swimming-water -person-water-arms-raised -person-waving -heart-slash -hearts -pentagon -rectangle-tall -square-half -square-half-stroke -box-arrow-down -box-arrow-down-arrow-up -box-arrow-down-magnifying-glass -box-isometric -box-isometric-tape -qrcode-read -shop-24 -store-24 -face-shaking -face-shaking-horizontal -face-shaking-vertical -circle-user-circle-check -circle-user-circle-exclamation -circle-user-circle-minus -circle-user-circle-moon -circle-user-circle-plus -circle-user-circle-question -circle-user-circle-user -circle-user-circle-xmark -circle-user-clock -user-beard -user-chef-hair-long -user-circle-minus -user-circle-plus -user-dashed -user-doctor-hair-mullet -user-hat-tie -user-hat-tie-magnifying-glass -user-key -user-message -user-microphone -user-pilot-hair-long -user-pilot-tie-hair-long -user-police-hair-long -user-police-tie-hair-long -user-question -user-sith -user-tie-hair-mullet -user-vneck-hair-mullet -plane-flying -plane-landing-gear -rocket-vertical -seat-airline-window -shuttle-space-vertical -car-key -car-siren -car-siren-on -scooter -snowmobile-blank -stair-car -truck-suv -unicycle -van -moon-star -rainbow-half -temperature-slash -dialpad -computer-mouse-button-left -computer-mouse-button-right -dot -folder-arrow-left -folder-arrow-right -wireless -circle-moon -person-meditating -baseball-bat -hockey-stick -arrow-u-turn-down-left -arrow-u-turn-down-right -arrow-u-turn-left-down -arrow-u-turn-left-up -arrow-u-turn-right-down -arrow-u-turn-right-up -arrow-u-turn-up-left -arrow-u-turn-up-right -u-turn-down-left -u-turn-down-right -u-turn-left-down -u-turn -u-turn-left-up -u-turn-right-down -u-turn-right-up -u-turn-up-left -u-turn-up-right -triple-chevrons-down -triple-chevrons-left -triple-chevrons-right -triple-chevrons-up -file-aiff -file-odf -file-tex -file-wav -droplet-plus -hand-holding-star -transmission -alarm-minus -file-brackets-curly -file-midi -midi -non-binary -rectangle-beta -shield-user -spiral -picture-in-picture -circle-half-horizontal -circle-half-stroke-horizontal -square-half-horizontal -square-half-stroke-horizontal -ship-large -butterfly -mobile-rotate -mobile-rotate-reverse -mobile-slash -mobile-vibrate -mobile-vibrate-slash -almost-equal-to -sneaker-running -horseshoe -single-quote-left -single-quote-right -bus-side -bus-stop -train-stop -septagon -heptagon -martini-glass-empty -glass-martini -music -magnifying-glass -search -heart -star -user -user-alt -user-large -film -film-alt -film-simple -table-cells-large -th-large -table-cells -th -table-list -th-list -check -xmark -close -multiply -remove -times -magnifying-glass-plus -search-plus -magnifying-glass-minus -search-minus -power-off -signal -signal-5 -signal-perfect -gear -cog -house -home -home-alt -home-lg-alt -clock -clock-four -road -download -inbox -arrow-rotate-right -arrow-right-rotate -arrow-rotate-forward -redo -arrows-rotate -refresh -sync -rectangle-list -list-alt -lock -flag -headphones -headphones-alt -headphones-simple -volume-off -volume-low -volume-down -volume-high -volume-up -qrcode -barcode -tag -tags -book -bookmark -print -camera -camera-alt -font -bold -italic -text-height -text-width -align-left -align-center -align-right -align-justify -list -list-squares -outdent -dedent -indent -video -video-camera -image -location-pin -map-marker -circle-half-stroke -adjust -droplet -tint -pen-to-square -edit -arrows-up-down-left-right -arrows -backward-step -step-backward -backward-fast -fast-backward -backward -play -pause -stop -forward -forward-fast -fast-forward -forward-step -step-forward -eject -chevron-left -chevron-right -circle-plus -plus-circle -circle-minus -minus-circle -circle-xmark -times-circle -xmark-circle -circle-check -check-circle -circle-question -question-circle -circle-info -info-circle -crosshairs -ban -cancel -arrow-left -arrow-right -arrow-up -arrow-down -share -mail-forward -expand -compress -minus -subtract -circle-exclamation -exclamation-circle -gift -leaf -fire -eye -eye-slash -triangle-exclamation -exclamation-triangle -warning -plane -calendar-days -calendar-alt -shuffle -random -comment -magnet -chevron-up -chevron-down -retweet -cart-shopping -shopping-cart -folder -folder-blank -folder-open -arrows-up-down -arrows-v -arrows-left-right -arrows-h -chart-bar -bar-chart -camera-retro -key -gears -cogs -comments -star-half -arrow-right-from-bracket -sign-out -thumbtack -thumb-tack -arrow-up-right-from-square -external-link -arrow-right-to-bracket -sign-in -trophy -upload -lemon -phone -square-phone -phone-square -unlock -credit-card -credit-card-alt -rss -feed -hard-drive -hdd -bullhorn -certificate -hand-point-right -hand-point-left -hand-point-up -hand-point-down -circle-arrow-left -arrow-circle-left -circle-arrow-right -arrow-circle-right -circle-arrow-up -arrow-circle-up -circle-arrow-down -arrow-circle-down -globe -wrench -list-check -tasks -filter -briefcase -up-down-left-right -arrows-alt -users -link -chain -cloud -flask -scissors -cut -copy -paperclip -floppy-disk -save -square -bars -navicon -list-ul -list-dots -list-ol -list-1-2 -list-numeric -strikethrough -underline -table -wand-magic -magic -truck -money-bill -caret-down -caret-up -caret-left -caret-right -table-columns -columns -sort -unsorted -sort-down -sort-desc -sort-up -sort-asc -envelope -arrow-rotate-left -arrow-left-rotate -arrow-rotate-back -arrow-rotate-backward -undo -gavel -legal -bolt -zap -sitemap -umbrella -paste -file-clipboard -lightbulb -arrow-right-arrow-left -exchange -cloud-arrow-down -cloud-download -cloud-download-alt -cloud-arrow-up -cloud-upload -cloud-upload-alt -user-doctor -user-md -stethoscope -suitcase -bell -mug-saucer -coffee -hospital -hospital-alt -hospital-wide -truck-medical -ambulance -suitcase-medical -medkit -jet-fighter -fighter-jet -beer-mug-empty -beer -square-h -h-square -square-plus -plus-square -angles-left -angle-double-left -angles-right -angle-double-right -angles-up -angle-double-up -angles-down -angle-double-down -angle-left -angle-right -angle-up -angle-down -laptop -tablet-button -mobile-button -quote-left -quote-left-alt -quote-right -quote-right-alt -spinner -circle -face-smile -smile -face-frown -frown -face-meh -meh -gamepad -keyboard -flag-checkered -terminal -code -reply-all -mail-reply-all -location-arrow -crop -code-branch -link-slash -chain-broken -chain-slash -unlink -info -superscript -subscript -eraser -puzzle-piece -microphone -microphone-slash -shield -shield-blank -calendar -fire-extinguisher -rocket -circle-chevron-left -chevron-circle-left -circle-chevron-right -chevron-circle-right -circle-chevron-up -chevron-circle-up -circle-chevron-down -chevron-circle-down -anchor -unlock-keyhole -unlock-alt -bullseye -ellipsis -ellipsis-h -ellipsis-vertical -ellipsis-v -square-rss -rss-square -circle-play -play-circle -ticket -square-minus -minus-square -arrow-turn-up -level-up -arrow-turn-down -level-down -square-check -check-square -square-pen -pen-square -pencil-square -square-arrow-up-right -external-link-square -share-from-square -share-square -compass -square-caret-down -caret-square-down -square-caret-up -caret-square-up -square-caret-right -caret-square-right -euro-sign -eur -euro -sterling-sign -gbp -pound-sign -rupee-sign -rupee -yen-sign -cny -jpy -rmb -yen -ruble-sign -rouble -rub -ruble -won-sign -krw -won -file -file-lines -file-alt -file-text -arrow-down-a-z -sort-alpha-asc -sort-alpha-down -arrow-up-a-z -sort-alpha-up -arrow-down-wide-short -sort-amount-asc -sort-amount-down -arrow-up-wide-short -sort-amount-up -arrow-down-1-9 -sort-numeric-asc -sort-numeric-down -arrow-up-1-9 -sort-numeric-up -thumbs-up -thumbs-down -arrow-down-long -long-arrow-down -arrow-up-long -long-arrow-up -arrow-left-long -long-arrow-left -arrow-right-long -long-arrow-right -person-dress -female -person -male -sun -moon -box-archive -archive -bug -square-caret-left -caret-square-left -circle-dot -dot-circle -wheelchair -lira-sign -shuttle-space -space-shuttle -square-envelope -envelope-square -building-columns -bank -institution -museum -university -graduation-cap -mortar-board -language -fax -building -child -paw -cube -cubes -recycle -car -automobile -taxi -cab -tree -database -file-pdf -file-word -file-excel -file-powerpoint -file-image -file-zipper -file-archive -file-audio -file-video -file-code -life-ring -circle-notch -paper-plane -clock-rotate-left -history -heading -header -paragraph -sliders -sliders-h -share-nodes -share-alt -square-share-nodes -share-alt-square -bomb -futbol -futbol-ball -soccer-ball -tty -teletype -binoculars -plug -newspaper -wifi -wifi-3 -wifi-strong -calculator -bell-slash -trash -copyright -eye-dropper -eye-dropper-empty -eyedropper -paintbrush -paint-brush -cake-candles -birthday-cake -cake -chart-area -area-chart -chart-pie -pie-chart -chart-line -line-chart -toggle-off -toggle-on -bicycle -bus -closed-captioning -shekel-sign -ils -shekel -sheqel -sheqel-sign -cart-plus -cart-arrow-down -diamond -ship -user-secret -motorcycle -street-view -heart-pulse -heartbeat -venus -mars -mercury -mars-and-venus -transgender -transgender-alt -venus-double -mars-double -venus-mars -mars-stroke -mars-stroke-up -mars-stroke-v -mars-stroke-right -mars-stroke-h -neuter -genderless -server -user-plus -user-xmark -user-times -bed -train -train-subway -subway -battery-full -battery -battery-5 -battery-three-quarters -battery-4 -battery-half -battery-3 -battery-quarter -battery-2 -battery-empty -battery-0 -arrow-pointer -mouse-pointer -i-cursor -object-group -object-ungroup -note-sticky -sticky-note -clone -scale-balanced -balance-scale -hourglass-start -hourglass-1 -hourglass-half -hourglass-2 -hourglass-end -hourglass-3 -hourglass -hourglass-empty -hand-back-fist -hand-rock -hand -hand-paper -hand-scissors -hand-lizard -hand-spock -hand-pointer -hand-peace -trademark -registered -tv -television -tv-alt -calendar-plus -calendar-minus -calendar-xmark -calendar-times -calendar-check -industry -map-pin -signs-post -map-signs -map -message -comment-alt -circle-pause -pause-circle -circle-stop -stop-circle -bag-shopping -shopping-bag -basket-shopping -shopping-basket -bluetooth -universal-access -person-walking-with-cane -blind -audio-description -phone-volume -volume-control-phone -braille -ear-listen -assistive-listening-systems -hands-asl-interpreting -american-sign-language-interpreting -asl-interpreting -hands-american-sign-language-interpreting -ear-deaf -deaf -deafness -hard-of-hearing -hands -sign-language -signing -eye-low-vision -low-vision -font-awesome -font-awesome-flag -font-awesome-logo-full -handshake -handshake-alt -handshake-simple -envelope-open -address-book -contact-book -address-card -contact-card -vcard -circle-user -user-circle -id-badge -id-card -drivers-license -temperature-full -temperature-4 -thermometer-4 -thermometer-full -temperature-three-quarters -temperature-3 -thermometer-3 -thermometer-three-quarters -temperature-half -temperature-2 -thermometer-2 -thermometer-half -temperature-quarter -temperature-1 -thermometer-1 -thermometer-quarter -temperature-empty -temperature-0 -thermometer-0 -thermometer-empty -shower -bath -bathtub -podcast -window-maximize -window-minimize -window-restore -square-xmark -times-square -xmark-square -microchip -snowflake -watch -volume-slash -fork -utensil-fork -knife -utensil-knife -spoon -utensil-spoon -fork-knife -utensils-alt -utensils -cutlery -circle-dollar -dollar-circle -usd-circle -square-dollar -dollar-square -usd-square -rotate-left -rotate-back -rotate-backward -undo-alt -trophy-star -trophy-alt -triangle -trash-can -trash-alt -hexagon-xmark -times-hexagon -xmark-hexagon -octagon-xmark -times-octagon -xmark-octagon -rotate -sync-alt -stopwatch -star-exclamation -spade -right-from-bracket -sign-out-alt -right-to-bracket -sign-in-alt -shield-check -scrubber -rotate-right -redo-alt -rotate-forward -rectangle -rectangle-landscape -rectangle-vertical -rectangle-portrait -rectangle-wide -square-question -question-square -poo -hexagon-plus -plus-hexagon -octagon-plus -plus-octagon -images -pencil -pencil-alt -pen -pen-clip -pen-alt -octagon -hexagon-minus -minus-hexagon -octagon-minus -minus-octagon -down-long -long-arrow-alt-down -left-long -long-arrow-alt-left -right-long -long-arrow-alt-right -up-long -long-arrow-alt-up -lock-keyhole -lock-alt -jack-o-lantern -square-info -info-square -inbox-in -inbox-arrow-down -inbox-out -inbox-arrow-up -hexagon -h1 -h2 -h3 -file-check -file-xmark -file-times -file-minus -file-plus -file-exclamation -file-pen -file-edit -arrows-maximize -expand-arrows -maximize -expand-arrows-alt -expand-wide -square-exclamation -exclamation-square -chevrons-down -chevron-double-down -chevrons-left -chevron-double-left -chevrons-right -chevron-double-right -chevrons-up -chevron-double-up -compress-wide -club -clipboard -square-chevron-down -chevron-square-down -square-chevron-left -chevron-square-left -square-chevron-right -chevron-square-right -square-chevron-up -chevron-square-up -circle-caret-down -caret-circle-down -circle-caret-left -caret-circle-left -circle-caret-right -caret-circle-right -circle-caret-up -caret-circle-up -calendar-pen -calendar-edit -calendar-exclamation -badge -badge-check -left-right -arrows-alt-h -up-down -arrows-alt-v -square-arrow-down -arrow-square-down -square-arrow-left -arrow-square-left -square-arrow-right -arrow-square-right -square-arrow-up -arrow-square-up -arrow-down-to-line -arrow-to-bottom -arrow-left-to-line -arrow-to-left -arrow-right-to-line -arrow-to-right -arrow-up-to-line -arrow-to-top -arrow-up-from-line -arrow-from-bottom -arrow-right-from-line -arrow-from-left -arrow-left-from-line -arrow-from-right -arrow-down-from-line -arrow-from-top -up-from-line -arrow-alt-from-bottom -right-from-line -arrow-alt-from-left -left-from-line -arrow-alt-from-right -down-from-line -arrow-alt-from-top -down-to-line -arrow-alt-to-bottom -left-to-line -arrow-alt-to-left -right-to-line -arrow-alt-to-right -up-to-line -arrow-alt-to-top -alarm-clock -square-down -arrow-alt-square-down -square-left -arrow-alt-square-left -square-right -arrow-alt-square-right -square-up -arrow-alt-square-up -down -arrow-alt-down -left -arrow-alt-left -right -arrow-alt-right -up -arrow-alt-up -circle-down -arrow-alt-circle-down -circle-left -arrow-alt-circle-left -circle-right -arrow-alt-circle-right -circle-up -arrow-alt-circle-up -up-right-from-square -external-link-alt -square-up-right -external-link-square-alt -arrows-retweet -retweet-alt -right-left -exchange-alt -repeat -arrows-repeat -repeat-alt -repeat-1 -arrows-repeat-1 -repeat-1-alt -share-all -battery-bolt -battery-slash -browser -code-commit -code-merge -credit-card-blank -credit-card-front -desktop -desktop-alt -ellipsis-stroke -ellipsis-h-alt -ellipsis-stroke-vertical -ellipsis-v-alt -gem -industry-windows -industry-alt -turn-down -level-down-alt -turn-up -level-up-alt -lock-open -lock-keyhole-open -lock-open-alt -location-dot -map-marker-alt -microphone-lines -microphone-alt -mobile-screen-button -mobile-alt -mobile -mobile-android -mobile-phone -mobile-screen -mobile-android-alt -money-bill-1 -money-bill-alt -phone-slash -plane-engines -plane-alt -image-portrait -portrait -reply -mail-reply -shield-halved -shield-alt -square-sliders -sliders-h-square -sliders-up -sliders-v -square-sliders-vertical -sliders-v-square -spinner-third -tablet-screen-button -tablet-alt -tablet -tablet-android -tablet-screen -tablet-android-alt -ticket-simple -ticket-alt -tree-deciduous -tree-alt -tv-retro -window -window-flip -window-alt -rectangle-xmark -rectangle-times -times-rectangle -window-close -down-left-and-up-right-to-center -compress-alt -up-right-and-down-left-from-center -expand-alt -baseball-bat-ball -baseball -baseball-ball -basketball -basketball-ball -basketball-hoop -bowling-ball -bowling-pins -boxing-glove -glove-boxing -chess -chess-bishop -chess-bishop-piece -chess-bishop-alt -chess-board -chess-clock -chess-clock-flip -chess-clock-alt -chess-king -chess-king-piece -chess-king-alt -chess-knight -chess-knight-piece -chess-knight-alt -chess-pawn -chess-pawn-piece -chess-pawn-alt -chess-queen -chess-queen-piece -chess-queen-alt -chess-rook -chess-rook-piece -chess-rook-alt -cricket-bat-ball -cricket -curling-stone -curling -dumbbell -field-hockey-stick-ball -field-hockey -football -football-ball -football-helmet -golf-ball-tee -golf-ball -golf-club -hockey-puck -hockey-sticks -luchador-mask -luchador -mask-luchador -flag-pennant -pennant -broom-ball -quidditch -quidditch-broom-ball -racquet -shuttlecock -square-full -table-tennis-paddle-ball -ping-pong-paddle-ball -table-tennis -tennis-ball -volleyball -volleyball-ball -whistle -hand-dots -allergies -bandage -band-aid -rectangle-barcode -barcode-alt -barcode-read -barcode-scan -box -box-check -boxes-stacked -boxes -boxes-alt -briefcase-medical -fire-flame-simple -burn -capsules -clipboard-check -clipboard-list -conveyor-belt -conveyor-belt-boxes -conveyor-belt-alt -person-dots-from-line -diagnoses -dna -dolly -dolly-box -dolly-empty -cart-flatbed -dolly-flatbed -cart-flatbed-boxes -dolly-flatbed-alt -cart-flatbed-empty -dolly-flatbed-empty -file-medical -file-waveform -file-medical-alt -kit-medical -first-aid -forklift -hand-holding-box -hands-holding-diamond -hand-receiving -circle-h -hospital-symbol -id-card-clip -id-card-alt -shelves -inventory -notes-medical -pallet -pallet-boxes -palette-boxes -pallet-alt -pills -prescription-bottle -prescription-bottle-medical -prescription-bottle-alt -bed-pulse -procedures -scanner-gun -scanner -scanner-keyboard -scanner-touchscreen -truck-fast -shipping-fast -truck-clock -shipping-timed -smoking -syringe -tablet-rugged -tablets -thermometer -vial -vials -warehouse -warehouse-full -warehouse-alt -weight-scale -weight -x-ray -blanket -book-heart -box-taped -box-alt -square-fragile -box-fragile -square-wine-glass-crack -box-open-full -box-full -box-heart -box-open -square-this-way-up -box-up -box-dollar -box-usd -message-check -comment-alt-check -message-dots -comment-alt-dots -messaging -message-pen -comment-alt-edit -message-edit -message-exclamation -comment-alt-exclamation -message-lines -comment-alt-lines -message-minus -comment-alt-minus -message-plus -comment-alt-plus -message-slash -comment-alt-slash -message-smile -comment-alt-smile -message-xmark -comment-alt-times -message-times -comment-check -comment-dots -commenting -comment-pen -comment-edit -comment-exclamation -comment-lines -comment-minus -comment-plus -comment-slash -comment-smile -comment-xmark -comment-times -messages -comments-alt -container-storage -couch -circle-dollar-to-slot -donate -dove -wine-glass-crack -fragile -hand-heart -hand-holding -hand-holding-heart -hand-holding-seedling -hand-holding-dollar -hand-holding-usd -hand-holding-droplet -hand-holding-water -hands-holding -hands-holding-heart -hands-heart -handshake-angle -hands-helping -hands-holding-dollar -hands-usd -circle-heart -heart-circle -square-heart -heart-square -house-heart -home-heart -lamp -leaf-heart -loveseat -couch-small -parachute-box -people-carry-box -people-carry -person-carry-box -person-carry -person-dolly -person-dolly-empty -phone-plus -piggy-bank -ramp-loading -ribbon -route -seedling -sprout -sign-hanging -sign -face-smile-wink -smile-wink -tape -truck-container -truck-ramp-couch -truck-couch -truck-ramp-box -truck-loading -truck-moving -truck-ramp -video-plus -video-slash -wine-glass -user-astronaut -user-check -user-clock -user-gear -user-cog -user-pen -user-edit -user-group -user-friends -user-graduate -user-lock -user-minus -user-ninja -user-shield -user-slash -user-alt-slash -user-large-slash -user-tag -user-tie -users-gear -users-cog -scale-unbalanced -balance-scale-left -scale-unbalanced-flip -balance-scale-right -blender -book-open -tower-broadcast -broadcast-tower -broom -chalkboard -blackboard -chalkboard-user -chalkboard-teacher -church -coins -compact-disc -crow -crown -dice -dice-five -dice-four -dice-one -dice-six -dice-three -dice-two -divide -door-closed -door-open -feather -frog -gas-pump -glasses -greater-than-equal -helicopter -infinity -kiwi-bird -less-than-equal -memory -microphone-lines-slash -microphone-alt-slash -money-bill-wave -money-bill-1-wave -money-bill-wave-alt -money-check -money-check-dollar -money-check-alt -not-equal -palette -square-parking -parking -diagram-project -project-diagram -receipt -robot -ruler -ruler-combined -ruler-horizontal -ruler-vertical -school -screwdriver -shoe-prints -skull -ban-smoking -smoking-ban -store -shop -store-alt -bars-staggered -reorder -stream -stroopwafel -toolbox -shirt -t-shirt -tshirt -person-walking -walking -wallet -face-angry -angry -archway -book-atlas -atlas -award -delete-left -backspace -bezier-curve -bong -brush -bus-simple -bus-alt -cannabis -check-double -martini-glass-citrus -cocktail -bell-concierge -concierge-bell -cookie -cookie-bite -crop-simple -crop-alt -tachograph-digital -digital-tachograph -face-dizzy -dizzy -compass-drafting -drafting-compass -drum -drum-steelpan -feather-pointed -feather-alt -file-contract -file-arrow-down -file-download -file-export -arrow-right-from-file -file-import -arrow-right-to-file -file-invoice -file-invoice-dollar -file-prescription -file-signature -file-arrow-up -file-upload -fill -fill-drip -fingerprint -fish -face-flushed -flushed -face-frown-open -frown-open -martini-glass -glass-martini-alt -earth-africa -globe-africa -earth-americas -earth -earth-america -globe-americas -earth-asia -globe-asia -face-grimace -grimace -face-grin -grin -face-grin-wide -grin-alt -face-grin-beam -grin-beam -face-grin-beam-sweat -grin-beam-sweat -face-grin-hearts -grin-hearts -face-grin-squint -grin-squint -face-grin-squint-tears -grin-squint-tears -face-grin-stars -grin-stars -face-grin-tears -grin-tears -face-grin-tongue -grin-tongue -face-grin-tongue-squint -grin-tongue-squint -face-grin-tongue-wink -grin-tongue-wink -face-grin-wink -grin-wink -grip -grid-horizontal -grip-horizontal -grip-vertical -grid-vertical -headset -highlighter -hot-tub-person -hot-tub -hotel -joint -face-kiss -kiss -face-kiss-beam -kiss-beam -face-kiss-wink-heart -kiss-wink-heart -face-laugh -laugh -face-laugh-beam -laugh-beam -face-laugh-squint -laugh-squint -face-laugh-wink -laugh-wink -cart-flatbed-suitcase -luggage-cart -map-location -map-marked -map-location-dot -map-marked-alt -marker -medal -face-meh-blank -meh-blank -face-rolling-eyes -meh-rolling-eyes -monument -mortar-pestle -paintbrush-fine -paint-brush-alt -paint-brush-fine -paintbrush-alt -paint-roller -passport -pen-fancy -pen-nib -pen-ruler -pencil-ruler -plane-arrival -plane-departure -prescription -face-sad-cry -sad-cry -face-sad-tear -sad-tear -van-shuttle -shuttle-van -signature -face-smile-beam -smile-beam -face-smile-plus -smile-plus -solar-panel -spa -splotch -spray-can -stamp -star-half-stroke -star-half-alt -suitcase-rolling -face-surprise -surprise -swatchbook -person-swimming -swimmer -water-ladder -ladder-water -swimming-pool -droplet-slash -tint-slash -face-tired -tired -tooth -umbrella-beach -weight-hanging -wine-glass-empty -wine-glass-alt -spray-can-sparkles -air-freshener -apple-whole -apple-alt -atom -atom-simple -atom-alt -backpack -bell-school -bell-school-slash -bone -bone-break -book-blank -book-alt -book-open-reader -book-reader -books -brain -bus-school -car-rear -car-alt -car-battery -battery-car -car-bump -car-burst -car-crash -car-garage -car-wrench -car-mechanic -car-side -car-tilt -car-wash -charging-station -clipboard-prescription -compass-slash -diploma -scroll-ribbon -diamond-turn-right -directions -do-not-enter -draw-circle -vector-circle -draw-polygon -vector-polygon -draw-square -vector-square -ear -engine-warning -engine-exclamation -file-certificate -file-award -gas-pump-slash -glasses-round -glasses-alt -globe-stand -wave-pulse -heart-rate -inhaler -kidneys -laptop-code -layer-group -layer-minus -layer-group-minus -layer-plus -layer-group-plus -lips -location-crosshairs -location -circle-location-arrow -location-circle -location-crosshairs-slash -location-slash -lungs -location-dot-slash -map-marker-alt-slash -location-check -map-marker-check -location-pen -map-marker-edit -location-exclamation -map-marker-exclamation -location-minus -map-marker-minus -location-plus -map-marker-plus -location-question -map-marker-question -location-pin-slash -map-marker-slash -location-smile -map-marker-smile -location-xmark -map-marker-times -map-marker-xmark -microscope -monitor-waveform -monitor-heart-rate -oil-can -oil-temperature -oil-temp -circle-parking -parking-circle -ban-parking -parking-circle-slash -square-parking-slash -parking-slash -pen-paintbrush -pencil-paintbrush -poop -route-highway -route-interstate -ruler-triangle -scalpel -scalpel-line-dashed -scalpel-path -shapes -triangle-circle-square -skeleton -star-of-life -steering-wheel -stomach -gauge -dashboard -gauge-med -tachometer-alt-average -gauge-high -tachometer-alt -tachometer-alt-fast -gauge-max -tachometer-alt-fastest -gauge-low -tachometer-alt-slow -gauge-min -tachometer-alt-slowest -gauge-simple -gauge-simple-med -tachometer-average -gauge-simple-high -tachometer -tachometer-fast -gauge-simple-max -tachometer-fastest -gauge-simple-low -tachometer-slow -gauge-simple-min -tachometer-slowest -teeth -teeth-open -masks-theater -theater-masks -tire -tire-flat -tire-pressure-warning -tire-rugged -toothbrush -traffic-cone -traffic-light -traffic-light-go -traffic-light-slow -traffic-light-stop -truck-monster -truck-pickup -screen-users -users-class -watch-fitness -abacus -rectangle-ad -ad -chart-mixed -analytics -ankh -badge-dollar -badge-percent -book-bible -bible -bullseye-arrow -bullseye-pointer -business-time -briefcase-clock -cabinet-filing -calculator-simple -calculator-alt -chart-line-down -chart-pie-simple -chart-pie-alt -city -message-dollar -comment-alt-dollar -comment-dollar -messages-dollar -comments-alt-dollar -comments-dollar -cross -dharmachakra -empty-set -envelope-open-dollar -envelope-open-text -file-chart-column -file-chart-line -file-chart-pie -file-spreadsheet -file-user -folder-minus -folder-plus -folder-xmark -folder-times -folders -function -filter-circle-dollar -funnel-dollar -gift-card -gopuram -hamsa -bahai -haykal -integral -intersection -jedi -book-journal-whills -journal-whills -kaaba -keynote -khanda -lambda -landmark -lightbulb-dollar -lightbulb-exclamation -lightbulb-on -lightbulb-slash -envelopes-bulk -mail-bulk -megaphone -menorah -brain-arrow-curved-right -mind-share -mosque -om -omega -spaghetti-monster-flying -pastafarianism -peace -phone-office -pi -place-of-worship -podium -square-poll-vertical -poll -square-poll-horizontal -poll-h -person-praying -pray -hands-praying -praying-hands -presentation-screen -presentation -print-slash -book-quran -quran -magnifying-glass-dollar -search-dollar -magnifying-glass-location -search-location -shredder -sigma -signal-weak -signal-1 -signal-fair -signal-2 -signal-good -signal-3 -signal-strong -signal-4 -signal-bars -signal-alt -signal-alt-4 -signal-bars-strong -signal-bars-weak -signal-alt-1 -signal-bars-fair -signal-alt-2 -signal-bars-good -signal-alt-3 -signal-bars-slash -signal-alt-slash -signal-slash -socks -square-root -square-root-variable -square-root-alt -star-and-crescent -star-of-david -synagogue -tally -tally-5 -theta -scroll-torah -torah -torii-gate -union -chart-user -user-chart -user-crown -user-group-crown -users-crown -value-absolute -vihara -volume -volume-medium -volume-xmark -volume-mute -volume-times -wifi-weak -wifi-1 -wifi-fair -wifi-2 -wifi-slash -yin-yang -acorn -alicorn -crate-apple -apple-crate -axe -axe-battle -badger-honey -bat -blender-phone -book-skull -book-dead -book-sparkles -book-spells -bow-arrow -campfire -campground -candle-holder -candy-corn -cat -cauldron -chair -chair-office -claw-marks -cloud-moon -cloud-sun -cup-togo -coffee-togo -coffin -corn -cow -dagger -dice-d10 -dice-d12 -dice-d20 -dice-d4 -dice-d6 -dice-d8 -dog -dog-leashed -dragon -drumstick -drumstick-bite -duck -dungeon -elephant -eye-evil -file-csv -hand-fist -fist-raised -fire-flame -flame -flask-round-poison -flask-poison -flask-round-potion -flask-potion -ghost -hammer -hammer-war -hand-holding-magic -hanukiah -hat-witch -hat-wizard -head-side -head-side-goggles -head-vr -helmet-battle -person-hiking -hiking -hippo -hockey-mask -hood-cloak -horse -house-chimney-crack -house-damage -hryvnia-sign -hryvnia -key-skeleton -kite -knife-kitchen -leaf-maple -leaf-oak -mace -mandolin -mask -monkey -mountain -mountains -narwhal -network-wired -otter -paw-simple -paw-alt -paw-claws -pegasus -pie -pig -pumpkin -rabbit -rabbit-running -rabbit-fast -ram -ring -person-running -running -scarecrow -scroll -scroll-old -scythe -sheep -shield-cross -shovel -skull-crossbones -slash -snake -spider -spider-black-widow -spider-web -squirrel -staff -sword -swords -toilet-paper -toilet-paper-alt -toilet-paper-blank -tombstone -tombstone-blank -tombstone-alt -tractor -treasure-chest -trees -turkey -turtle -unicorn -user-injured -vr-cardboard -wand -wand-sparkles -whale -wheat -wind -wine-bottle -ballot -ballot-check -booth-curtain -box-ballot -calendar-star -clipboard-list-check -cloud-drizzle -cloud-hail -cloud-hail-mixed -cloud-meatball -cloud-moon-rain -cloud-rain -cloud-rainbow -cloud-showers -cloud-showers-heavy -cloud-sleet -cloud-snow -cloud-sun-rain -clouds -clouds-moon -clouds-sun -democrat -droplet-degree -dewpoint -eclipse -moon-over-sun -eclipse-alt -fire-smoke -flag-swallowtail -flag-alt -flag-usa -cloud-fog -fog -house-water -house-flood -droplet-percent -humidity -hurricane -landmark-dome -landmark-alt -meteor -moon-cloud -moon-stars -person-booth -person-sign -podium-star -poll-people -poo-storm -poo-bolt -rainbow -raindrops -republican -smog -smoke -snow-blowing -stars -sun-cloud -sun-dust -sun-haze -sunrise -sunset -temperature-snow -temperature-frigid -temperature-high -temperature-sun -temperature-hot -temperature-low -cloud-bolt -thunderstorm -cloud-bolt-moon -thunderstorm-moon -cloud-bolt-sun -thunderstorm-sun -tornado -volcano -xmark-to-slot -times-to-slot -vote-nay -check-to-slot -vote-yea -water -water-arrow-down -water-lower -water-arrow-up -water-rise -wind-warning -wind-circle-exclamation -windsock -angel -baby -baby-carriage -carriage-baby -ball-pile -bells -biohazard -blog -boot -calendar-day -calendar-week -candy-cane -carrot -cash-register -chart-network -chimney -minimize -compress-arrows-alt -deer -deer-rudolph -dreidel -dumpster -dumpster-fire -ear-muffs -ethernet -fireplace -snowman-head -frosty-head -gifts -gingerbread-man -champagne-glass -glass-champagne -champagne-glasses -glass-cheers -whiskey-glass -glass-whiskey -whiskey-glass-ice -glass-whiskey-rocks -earth-europe -globe-europe -globe-snow -grip-lines -grip-lines-vertical -guitar -hat-santa -hat-winter -heart-crack -heart-broken -holly-berry -horse-head -ice-skate -icicles -igloo -lights-holiday -mistletoe -mitten -mug-hot -mug-marshmallows -ornament -radiation -circle-radiation -radiation-alt -restroom -rv -satellite -satellite-dish -scarf -sd-card -shovel-snow -sim-card -person-skating -skating -person-ski-jumping -ski-jump -person-ski-lift -ski-lift -person-skiing -skiing -person-skiing-nordic -skiing-nordic -person-sledding -sledding -sleigh -comment-sms -sms -person-snowboarding -snowboarding -snowflakes -snowman -person-snowmobiling -snowmobile -snowplow -star-christmas -stocking -tenge-sign -tenge -toilet -screwdriver-wrench -tools -cable-car -tram -tree-christmas -tree-decorated -tree-large -truck-plow -wreath -fire-flame-curved -fire-alt -bacon -book-medical -book-user -books-medical -brackets-square -brackets -brackets-curly -bread-loaf -bread-slice -burrito -chart-scatter -cheese -cheese-swiss -burger-cheese -cheeseburger -house-chimney-medical -clinic-medical -clipboard-user -message-medical -comment-alt-medical -comment-medical -croissant -crutch -crutches -ban-bug -debug -disease -egg -egg-fried -files-medical -fish-cooked -flower -flower-daffodil -flower-tulip -folder-tree -french-fries -glass -burger -hamburger -hand-middle-finger -helmet-safety -hard-hat -hat-hard -head-side-brain -head-side-medical -hospital-user -hospitals -hotdog -ice-cream -island-tropical -island-tree-palm -laptop-medical -mailbox -meat -pager -pepper-hot -pizza -pizza-slice -popcorn -print-magnifying-glass -print-search -rings-wedding -sack -sack-dollar -salad -bowl-salad -sandwich -sausage -shish-kebab -sickle -bowl-hot -soup -steak -stretcher -taco -book-tanakh -tanakh -bars-progress -tasks-alt -trash-arrow-up -trash-restore -trash-can-arrow-up -trash-restore-alt -tree-palm -user-helmet-safety -user-construction -user-hard-hat -user-headset -user-doctor-message -user-md-chat -user-nurse -users-medical -walker -camera-web -webcam -camera-web-slash -webcam-slash -wave-square -alarm-exclamation -alarm-plus -alarm-snooze -align-slash -bags-shopping -bell-exclamation -bell-plus -person-biking -biking -person-biking-mountain -biking-mountain -border-all -border-bottom -border-inner -border-left -border-none -border-outer -border-right -border-top-left -border-style -border-bottom-right -border-style-alt -border-top -bring-forward -bring-front -burger-soda -car-building -car-bus -cars -coin -triangle-person-digging -construction -person-digging -digging -drone -drone-front -drone-alt -dryer -dryer-heat -dryer-alt -fan -farm -barn-silo -file-magnifying-glass -file-search -font-case -game-board -game-board-simple -game-board-alt -glass-citrus -h4 -hat-chef -horizontal-rule -icons -heart-music-camera-bolt -symbols -icons-alt -kerning -line-columns -line-height -money-check-pen -money-check-edit -money-check-dollar-pen -money-check-edit-alt -mug -mug-tea -overline -file-dashed-line -page-break -paragraph-left -paragraph-rtl -phone-flip -phone-alt -laptop-mobile -phone-laptop -square-phone-flip -phone-square-alt -photo-film -photo-video -text-slash -remove-format -send-back -send-backward -snooze -zzz -arrow-down-z-a -sort-alpha-desc -sort-alpha-down-alt -arrow-up-z-a -sort-alpha-up-alt -arrow-down-arrow-up -sort-alt -arrow-down-short-wide -sort-amount-desc -sort-amount-down-alt -arrow-up-short-wide -sort-amount-up-alt -arrow-down-9-1 -sort-numeric-desc -sort-numeric-down-alt -arrow-up-9-1 -sort-numeric-up-alt -arrow-down-triangle-square -sort-shapes-down -arrow-down-square-triangle -sort-shapes-down-alt -arrow-up-triangle-square -sort-shapes-up -arrow-up-square-triangle -sort-shapes-up-alt -arrow-down-big-small -sort-size-down -arrow-down-small-big -sort-size-down-alt -arrow-up-big-small -sort-size-up -arrow-up-small-big -sort-size-up-alt -sparkles -spell-check -sunglasses -text -text-size -trash-undo -trash-arrow-turn-left -trash-can-undo -trash-can-arrow-turn-left -trash-undo-alt -voicemail -washing-machine -washer -wave-sine -wave-triangle -wind-turbine -border-center-h -border-center-v -album -album-collection -amp-guitar -badge-sheriff -banjo -cassette-betamax -betamax -boombox -cactus -camcorder -video-handheld -camera-movie -camera-polaroid -cassette-tape -camera-cctv -cctv -clarinet -cloud-music -message-music -comment-alt-music -comment-music -computer-classic -computer-speaker -cowbell -cowbell-circle-plus -cowbell-more -disc-drive -file-music -film-canister -film-cannister -flashlight -flute -flux-capacitor -game-console-handheld -gramophone -guitar-electric -guitars -hat-cowboy -hat-cowboy-side -head-side-headphones -horse-saddle -image-polaroid -joystick -jug -kazoo -lasso -list-music -microphone-stand -computer-mouse -mouse -computer-mouse-scrollwheel -mouse-alt -mp3-player -music-note -music-alt -music-note-slash -music-alt-slash -music-slash -phone-rotary -piano -piano-keyboard -projector -radio -radio-tuner -radio-alt -record-vinyl -router -saxophone-fire -sax-hot -saxophone -signal-stream -skull-cow -speaker -speakers -triangle-instrument -triangle-music -trumpet -turntable -tv-music -typewriter -usb-drive -user-cowboy -user-music -cassette-vhs -vhs -violin -wagon-covered -walkie-talkie -watch-calculator -waveform -waveform-lines -waveform-path -scanner-image -air-conditioner -alien -alien-8bit -alien-monster -bed-front -bed-alt -bed-bunk -bed-empty -bell-on -blinds -blinds-open -blinds-raised -camera-security -camera-home -caravan diff --git a/src/Resolvers/BaseSchemaResolver.php b/src/Resolvers/BaseSchemaResolver.php deleted file mode 100644 index 50a50f7..0000000 --- a/src/Resolvers/BaseSchemaResolver.php +++ /dev/null @@ -1,92 +0,0 @@ -table = $table; - $this->columns = $columns; - } - - public function resolve(): TableProperties - { - $tableColumns = $this->getColumnsDefinitionsFromTable(); - - $skip_columns = config('generators.skip_columns'); - - $table_fields = []; - $soft_deletes = false; - $found_updated_at = false; - $found_created_at = false; - $key_name = ''; - - foreach ($tableColumns as $column) { - $field = $this->getField($column); - $field_type = $this->resolveColumnFieldType($column); - - if ($field == 'deleted_at' && ($field_type instanceof DateTimeField)) { - $soft_deletes = true; - } - - if ($field == 'updated_at' && ($field_type instanceof DateTimeField)) { - $found_updated_at = true; - } - - if ($field == 'created_at' && ($field_type instanceof DateTimeField)) { - $found_created_at = true; - } - - // If specific columns where supplied only process those... - if (! empty($this->columns()) && ! in_array($field, $this->columns())) { - continue; - } - - // If column should be skipped - if (in_array($field, $skip_columns)) { - continue; - } - - // We do not need a rule for auto increments - if ($this->isAutoIncrement($column)) { - $key_name = $field; - continue; - } - - if ($field_type) { - $table_fields[$field] = $field_type; - } - } - - return new TableProperties($table_fields, $key_name, $soft_deletes, $found_updated_at && $found_created_at); - } - - protected function table() - { - return $this->table; - } - - protected function columns() - { - return $this->columns; - } - - abstract protected function isAutoIncrement($column): bool; - - abstract protected function getField($column): string; - - abstract protected function getColumnsDefinitionsFromTable(); - - abstract protected function resolveColumnFieldType(stdClass $column): ?Field; -} diff --git a/src/Resolvers/SchemaResolverMySql.php b/src/Resolvers/SchemaResolverMySql.php deleted file mode 100644 index 58bce33..0000000 --- a/src/Resolvers/SchemaResolverMySql.php +++ /dev/null @@ -1,301 +0,0 @@ - [ - 'unsigned' => ['0', '255'], - 'signed' => ['-128', '127'], - ], - 'smallint' => [ - 'unsigned' => ['0', '65535'], - 'signed' => ['-32768', '32767'], - ], - 'mediumint' => [ - 'unsigned' => ['0', '16777215'], - 'signed' => ['-8388608', '8388607'], - ], - 'int' => [ - 'unsigned' => ['0', '4294967295'], - 'signed' => ['-2147483648', '2147483647'], - ], - 'bigint' => [ - 'unsigned' => ['0', '18446744073709551615'], - 'signed' => ['-9223372036854775808', '9223372036854775807'], - ], - ]; - - public static array $textTypes = [ - 'tinytext' => 255, - 'text' => 65535, - 'mediumtext' => 16777215, - 'longtext' => 4294967295, - ]; - - protected function getColumnsDefinitionsFromTable() - { - $databaseName = config('database.connections.mysql.database'); - $tableName = $this->table(); - - $tableColumns = collect(DB::select('SHOW COLUMNS FROM `'.$tableName.'`'))->keyBy('Field')->toArray(); - - $foreignKeys = DB::select(" - SELECT k.COLUMN_NAME, k.REFERENCED_TABLE_NAME, k.REFERENCED_COLUMN_NAME - FROM information_schema.TABLE_CONSTRAINTS i - LEFT JOIN information_schema.KEY_COLUMN_USAGE k ON i.CONSTRAINT_NAME = k.CONSTRAINT_NAME - WHERE i.CONSTRAINT_TYPE = 'FOREIGN KEY' - AND i.TABLE_SCHEMA = '{$databaseName}' - AND i.TABLE_NAME = '{$tableName}' - "); - - foreach ($foreignKeys as $foreignKey) { - $tableColumns[$foreignKey->COLUMN_NAME]->Foreign = [ - 'table' => $foreignKey->REFERENCED_TABLE_NAME, - 'id' => $foreignKey->REFERENCED_COLUMN_NAME, - ]; - } - - $columnComments = DB::select(" - SELECT COLUMN_NAME, COLUMN_COMMENT - FROM information_schema.COLUMNS i - WHERE i.TABLE_SCHEMA = '{$databaseName}' - AND i.TABLE_NAME = '{$tableName}' - "); - - foreach ($columnComments as $comment) { - $tableColumns[$comment->COLUMN_NAME]->Comment = $comment->COLUMN_COMMENT; - } - - return $tableColumns; - } - - protected function resolveColumnFieldType(stdClass $column): ?Field - { - $name = $this->getField($column); - - $is_unique = $column->Key === 'UNI'; - $is_nullable = $column->Null === 'YES'; - $default = $column->Default; - - if (! empty($column->Foreign)) { - return new ForeignKeyField( - $name, - $column->Foreign['table'], - $column->Foreign['id'], - $is_nullable, - unique: $is_unique - ); - } - - $type = Str::of($column->Type); - - switch (true) { - case $type->contains('enum') - || $type->contains('set') - || Str::startsWith($column->Comment, 'enum:'): - - if (Str::startsWith($column->Comment, 'enum:')) { - $enum_class = Str::after($column->Comment, 'enum:'); - $options = []; - } else { - preg_match_all("/'([^']*)'/", $type, $matches); - $options = $matches[1]; - $enum_class = null; - } - - return new EnumField( - $name, - $options, - $enum_class, - nullable: $is_nullable, - default: $default, - unique: $is_unique - ); - break; - - case $type == 'tinyint(1)' && config('generators.tinyint1_to_bool'): - return new BooleanField( - $name, - $is_nullable, - default: $default == true, - unique: $is_unique - ); - break; - - case $type->contains('char'): - $max = filter_var($type, FILTER_SANITIZE_NUMBER_INT); - - return new StringField( - $name, - $is_nullable, - default: $default, - max: $max, - unique: $is_unique - ); - break; - - case $type == 'tinytext': - case $type == 'mediumtext': - case $type == 'longtext': - case $type == 'text': - $text_type = $type->toString(); - - if (! array_key_exists($text_type, self::$textTypes)) { - $text_type = 'text'; - } - - $max = self::$textTypes[$text_type]; - - return new TextField( - $name, - $is_nullable, - default: $default, - max: $max, - unique: $is_unique - ); - break; - - case $type->contains('int'): - - $unsigned = $type->contains('unsigned'); - - $sign = $unsigned ? 'unsigned' : 'signed'; - $intType = $type->before(' unsigned')->__toString(); - - // prevent int(xx) for mysql - $intType = preg_replace("/\([^)]+\)/", '', $intType); - - if (! array_key_exists($intType, self::$integerTypes)) { - $intType = 'int'; - } - - $min = self::$integerTypes[$intType][$sign][0]; - $max = self::$integerTypes[$intType][$sign][1]; - - return new IntegerField( - $name, - $is_nullable, - default: $default, - min: $min, - max: $max, - unsigned: $unsigned, - unique: $is_unique - ); - break; - - case $type->contains('double') || - $type->contains('decimal') || - $type->contains('dec') || - $type->contains('float'): - $unsigned = $type->contains('unsigned'); - [$total_digits, $places] = $type->between('(', ')') - ->explode(','); - - $total_digits = (int) $total_digits; - $places = (int) $places; - - $whole_number_digits = ($total_digits - $places) - ($unsigned ? 0 : 1); - - $max = Str::repeat('9', $whole_number_digits); - $min = $unsigned ? 0 : '-' . $max; - - return new DecimalField( - $name, - $total_digits, - $places, - $is_nullable, - default: $default, - min: (int) $min, - max: (int) $max, - unsigned: $unsigned, - unique: $is_unique - ); - - break; - - case $type->contains('year'): - return new YearField( - $name, - $is_nullable, - default: $default, - min: 1900, - max: 2100, - unique: $is_unique - ); - break; - - - case $type == 'datetime' || $type == 'timestamp': - return new DateTimeField( - $name, - $is_nullable, - default: $default, - unique: $is_unique - ); - break; - - case $type == 'date': - return new DateField( - $name, - $is_nullable, - default: $default, - unique: $is_unique - ); - break; - - case $type == 'time': - return new TimeField( - $name, - $is_nullable, - default: $default, - unique: $is_unique - ); - break; - - case $type == 'json': - return new JsonField( - $name, - $is_nullable, - default: $default, - unique: $is_unique - ); - break; - - default: - // I think we skip BINARY and BLOB for now - break; - } - - return null; - } - - protected function isAutoIncrement($column): bool - { - return $column->Extra === 'auto_increment'; - } - - protected function getField($column): string - { - return $column->Field; - } -} diff --git a/src/Resource/MediaCollection.php b/src/Resource/MediaCollection.php new file mode 100644 index 0000000..8852112 --- /dev/null +++ b/src/Resource/MediaCollection.php @@ -0,0 +1,61 @@ + + */ + public static function parse(string $definition): array + { + $collections = []; + + foreach (array_filter(array_map('trim', explode(',', $definition))) as $part) { + $segments = array_map('trim', explode(':', $part)); + $name = Str::snake(array_shift($segments)); + + $type = 'image'; + $multiple = false; + + foreach ($segments as $segment) { + if ($segment === 'multiple') { + $multiple = true; + + continue; + } + + $type = $segment; + } + + $collections[] = new self($name, $type, $multiple); + } + + return $collections; + } + + public function label(): string + { + return Str::ucfirst(str_replace('_', ' ', $this->name)); + } +} diff --git a/src/Resource/ResourceDefinition.php b/src/Resource/ResourceDefinition.php new file mode 100644 index 0000000..d0c6a76 --- /dev/null +++ b/src/Resource/ResourceDefinition.php @@ -0,0 +1,563 @@ + $fields every field, keyed by column + * @param array $belongs_to + * @param array $has_many + * @param array $belongs_to_many + * @param array $index_columns columns (or belongsToMany relation names) on the index table + * @param array $form_columns columns (or belongsToMany relation names) in the form + * @param array $show_columns columns on the show page + * @param array $media + * @param array $translatable translatable columns + */ + public function __construct( + public readonly Table $table, + public readonly array $fields, + public readonly string $key_name, + public readonly string $name_field, + public readonly array $belongs_to = [], + public readonly array $has_many = [], + public readonly array $belongs_to_many = [], + public readonly array $index_columns = [], + public readonly array $form_columns = [], + public readonly array $show_columns = [], + public readonly ?ForeignKeyField $parent = null, + public readonly ?string $parent_name_field = null, + public readonly array $media = [], + public readonly string $views = 'blade', + public readonly array $translatable = [], + public readonly ?string $user_type = null, + public readonly ?string $parent_relation = null, + public readonly ?string $user_guard = null, + ) {} + + /** + * Whether the resource is a javaabu/auth user type (generate:auth). + */ + public function isUserType(): bool + { + return $this->user_type !== null; + } + + /* + |-------------------------------------------------------------------------- + | Names + |-------------------------------------------------------------------------- + */ + + public function table(): string + { + return $this->table->name; + } + + public function model(): string + { + return StringCaser::singularStudly($this->table()); + } + + public function modelClass(): string + { + return $this->namespace('models') . '\\' . $this->model(); + } + + /** product */ + public function singular(): string + { + return StringCaser::singularSnake($this->table()); + } + + /** products */ + public function plural(): string + { + return StringCaser::pluralSnake($this->table()); + } + + /** Products */ + public function pluralStudly(): string + { + return StringCaser::pluralStudly($this->table()); + } + + /** blog-posts */ + public function kebab(): string + { + return StringCaser::pluralKebab($this->table()); + } + + /** blog_post: route parameter and variable */ + public function variable(): string + { + return $this->singular(); + } + + /** blog_posts */ + public function pluralVariable(): string + { + return $this->plural(); + } + + /** product */ + public function singularLower(): string + { + return StringCaser::singularLower($this->table()); + } + + /** products */ + public function pluralLower(): string + { + return StringCaser::pluralLower($this->table()); + } + + /** Product */ + public function singularLabel(): string + { + return Str::ucfirst($this->singularLower()); + } + + /** Products */ + public function pluralLabel(): string + { + return Str::ucfirst($this->pluralLower()); + } + + /** The resource part of the permission names: products */ + public function permissionResource(): string + { + return $this->plural(); + } + + public function permission(string $action): string + { + return "{$action}_{$this->permissionResource()}"; + } + + /* + |-------------------------------------------------------------------------- + | Classes + |-------------------------------------------------------------------------- + */ + + public function namespace(string $type): string + { + $defaults = [ + 'models' => 'App\\Models', + 'policies' => 'App\\Policies', + 'filters' => 'App\\Filters', + 'bulk_actions' => 'App\\BulkActions', + 'forms' => 'App\\Stencil', + 'controllers' => 'App\\Http\\Controllers', + 'resources' => 'App\\Http\\Resources', + 'exports' => 'App\\Exports', + 'factories' => 'Database\\Factories', + 'tests' => 'Tests\\Feature', + ]; + + $namespace = trim((string) config("generators.namespaces.{$type}", $defaults[$type] ?? 'App'), '\\'); + + if (in_array($type, ['controllers', 'resources', 'tests'], true) && ($area = $this->areaNamespace())) { + $namespace .= '\\' . $area; + } + + return $namespace; + } + + public function policyClass(): string + { + return $this->namespace('policies') . '\\' . $this->model() . 'Policy'; + } + + public function filtersClass(): string + { + return $this->namespace('filters') . '\\' . $this->model() . 'Filters'; + } + + public function bulkActionsClass(): string + { + return $this->namespace('bulk_actions') . '\\' . $this->model() . 'BulkActions'; + } + + public function formClass(): string + { + return $this->namespace('forms') . '\\' . $this->model() . 'Form'; + } + + public function controllerClass(): string + { + return $this->namespace('controllers') . '\\' . $this->pluralStudly() . 'Controller'; + } + + public function resourceClass(): string + { + return $this->namespace('resources') . '\\' . $this->model() . 'Resource'; + } + + public function exportClass(): string + { + return $this->namespace('exports') . '\\' . $this->pluralStudly() . 'Export'; + } + + public function factoryClass(): string + { + return $this->namespace('factories') . '\\' . $this->model() . 'Factory'; + } + + public function testClass(): string + { + return $this->namespace('tests') . '\\' . $this->pluralStudly() . 'ControllerTest'; + } + + /** + * The path of a class under the app, from its namespace. + */ + public function classPath(string $class): string + { + $roots = [ + 'App\\' => app_path(), + 'Database\\Factories\\' => database_path('factories'), + 'Database\\Seeders\\' => database_path('seeders'), + 'Tests\\' => base_path('tests'), + ]; + + foreach ($roots as $namespace => $path) { + if (str_starts_with($class, $namespace)) { + return $path . '/' . str_replace('\\', '/', substr($class, strlen($namespace))) . '.php'; + } + } + + return base_path(str_replace('\\', '/', $class) . '.php'); + } + + /* + |-------------------------------------------------------------------------- + | Admin area: routes, views and pages + |-------------------------------------------------------------------------- + */ + + public function area(string $key, mixed $default = null): mixed + { + return config("generators.admin.{$key}", $default); + } + + /** Admin: the controllers' sub-namespace */ + public function areaNamespace(): string + { + return trim((string) $this->area('namespace', 'Admin'), '\\'); + } + + /** admin. */ + public function routeNamePrefix(): string + { + $name = (string) $this->area('route_name', 'admin.'); + + return $name === '' ? '' : rtrim($name, '.') . '.'; + } + + /** admin.products or admin.categories.products */ + public function routeName(string $action = ''): string + { + $name = $this->routeNamePrefix() . $this->routeResource(); + + return $action === '' ? $name : "{$name}.{$action}"; + } + + /** products or categories.products: the Route::resource() name */ + public function routeResource(): string + { + if (! $this->parent) { + return $this->kebab(); + } + + return StringCaser::pluralKebab($this->parent->getRelatedTable()) . '.' . $this->kebab(); + } + + /** admin.products.show with its route arguments, as PHP code */ + public function route(string $action, ?string $record = null, bool $parent_from_record = false): string + { + $arguments = []; + + if ($this->parent) { + $arguments[] = $parent_from_record && $record + ? "{$record}->{$this->parent->getRelationName()}" + : '$' . $this->parentVariable(); + } + + if ($record) { + $arguments[] = $record; + } + + $name = "'{$this->routeName($action)}'"; + + if (! $arguments) { + return "route({$name})"; + } + + if (count($arguments) === 1) { + return "route({$name}, {$arguments[0]})"; + } + + return "route({$name}, [" . implode(', ', $arguments) . '])'; + } + + /** /admin/products, for tests */ + public function url(string $suffix = '', ?string $record = null): string + { + $prefix = trim((string) $this->area('route_prefix', 'admin'), '/'); + + $segments = array_filter([$prefix]); + + if ($this->parent) { + $segments[] = StringCaser::pluralKebab($this->parent->getRelatedTable()); + $segments[] = '{$' . $this->parentVariable() . '->' . $this->parentKey() . '}'; + } + + $segments[] = $this->kebab(); + + if ($record) { + $segments[] = '{' . $record . '->' . $this->key_name . '}'; + } + + if ($suffix !== '') { + $segments[] = $suffix; + } + + return '/' . implode('/', $segments); + } + + /** admin.products, the Blade view folder */ + public function viewName(string $view): string + { + $prefix = trim((string) $this->area('view_prefix', 'admin'), '.'); + + return ltrim("{$prefix}.{$this->kebab()}.{$view}", '.'); + } + + public function viewPath(string $view): string + { + return resource_path('views/' . str_replace('.', '/', $this->viewName($view)) . '.blade.php'); + } + + /** admin/products/Index, the Inertia page name */ + public function pageName(string $page): string + { + $prefix = trim((string) $this->area('page_prefix', 'admin'), '/'); + + return ltrim("{$prefix}/{$this->kebab()}/{$page}", '/'); + } + + public function pagePath(string $page): string + { + return resource_path('js/pages/' . $this->pageName($page) . '.tsx'); + } + + public function guard(): ?string + { + return $this->area('guard') ?: null; + } + + public function isInertia(): bool + { + return $this->views === 'inertia'; + } + + /* + |-------------------------------------------------------------------------- + | Fields + |-------------------------------------------------------------------------- + */ + + public function field(string $column): ?Field + { + return $this->fields[$column] ?? null; + } + + public function nameField(): string + { + return $this->name_field; + } + + public function hasSoftDeletes(): bool + { + return $this->table->hasSoftDeletes(); + } + + public function hasTimestamps(): bool + { + return $this->table->hasTimestamps(); + } + + /** + * @return array + */ + public function formFields(): array + { + return $this->pick($this->form_columns); + } + + /** + * @return array + */ + public function indexFields(): array + { + return array_filter($this->pick($this->index_columns), fn (Field $field) => $field->isShown()); + } + + /** + * @return array + */ + public function showFields(): array + { + return array_filter($this->pick($this->show_columns), fn (Field $field) => $field->isShown()); + } + + /** + * belongsToMany relations in the form. + * + * @return array + */ + public function formRelations(): array + { + return $this->pickRelations($this->form_columns); + } + + /** + * @return array + */ + public function indexRelations(): array + { + return $this->pickRelations($this->index_columns); + } + + /** + * @return array + */ + public function showRelations(): array + { + return $this->pickRelations($this->show_columns); + } + + /** + * Fields that are searchable by the text filter. + * + * @return array + */ + public function searchableColumns(): array + { + $columns = []; + + foreach ($this->fields as $column => $field) { + if ($field->isSearchable() && ! in_array($column, $this->translatable, true)) { + $columns[] = $column; + } + } + + return $columns; + } + + public function isTranslatable(): bool + { + return $this->translatable !== []; + } + + public function hasMedia(): bool + { + return $this->media !== []; + } + + public function media(string $collection): ?MediaCollection + { + foreach ($this->media as $media) { + if ($media->name === $collection) { + return $media; + } + } + + return null; + } + + /* + |-------------------------------------------------------------------------- + | Nesting + |-------------------------------------------------------------------------- + */ + + public function isNested(): bool + { + return $this->parent !== null; + } + + /** category */ + public function parentVariable(): string + { + return $this->parent ? StringCaser::singularSnake($this->parent->getRelatedTable()) : ''; + } + + /** Category */ + public function parentModel(): string + { + return $this->parent ? StringCaser::singularStudly($this->parent->getRelatedTable()) : ''; + } + + public function parentModelClass(): string + { + return $this->namespace('models') . '\\' . $this->parentModel(); + } + + /** The relation on the parent: products */ + public function parentRelation(): string + { + return $this->parent_relation ?? StringCaser::pluralCamel($this->table()); + } + + public function parentKey(): string + { + return $this->parent?->getRelatedKey() ?? 'id'; + } + + /** + * @param array $columns + * @return array + */ + protected function pick(array $columns): array + { + $fields = []; + + foreach ($columns as $column) { + if (isset($this->fields[$column])) { + $fields[$column] = $this->fields[$column]; + } + } + + return $fields; + } + + /** + * @param array $names + * @return array + */ + protected function pickRelations(array $names): array + { + return array_values(array_filter( + $this->belongs_to_many, + fn (BelongsToMany $relation) => in_array($relation->name, $names, true) + )); + } +} diff --git a/src/Resource/ResourceOptions.php b/src/Resource/ResourceOptions.php new file mode 100644 index 0000000..dc196ac --- /dev/null +++ b/src/Resource/ResourceOptions.php @@ -0,0 +1,73 @@ + $columns only these columns (empty for all) + * @param array|null $index columns on the index table, null for the defaults + * @param array|null $form fields in the form, null for the defaults + * @param array|null $show fields on the show page, null for the defaults + * @param array $media + * @param array|bool|null $translatable translatable columns, true to detect them, false for none, null for config + */ + public function __construct( + public array $columns = [], + public ?array $index = null, + public ?array $form = null, + public ?array $show = null, + public ?string $parent = null, + public array $media = [], + public ?string $views = null, + public array|bool|null $translatable = null, + public ?string $user_type = null, + public ?string $user_guard = null, + ) {} + + /** + * @param array $options + */ + public static function fromArray(array $options): self + { + $list = function (mixed $value): ?array { + if ($value === null || $value === '' || $value === false) { + return null; + } + + if (is_array($value)) { + return array_values(array_filter(array_map('trim', $value))); + } + + return array_values(array_filter(array_map('trim', explode(',', (string) $value)))); + }; + + $translatable = $options['translatable'] ?? null; + + if (is_string($translatable)) { + $translatable = in_array($translatable, ['1', 'true', 'auto', ''], true) ? true : $list($translatable); + } + + $media = $options['media'] ?? []; + + if (is_string($media)) { + $media = MediaCollection::parse($media); + } + + return new self( + columns: $list($options['columns'] ?? null) ?? [], + index: $list($options['index'] ?? null), + form: $list($options['form'] ?? null), + show: $list($options['show'] ?? null), + parent: ($options['parent'] ?? null) ?: null, + media: $media ?: [], + views: ($options['views'] ?? null) ?: null, + translatable: $translatable, + user_type: ($options['user_type'] ?? null) ?: null, + user_guard: ($options['user_guard'] ?? null) ?: null, + ); + } +} diff --git a/src/Resource/ResourceResolver.php b/src/Resource/ResourceResolver.php new file mode 100644 index 0000000..94349ad --- /dev/null +++ b/src/Resource/ResourceResolver.php @@ -0,0 +1,436 @@ +inspector->table($table_name); + + $this->ensureColumnsExist($table, $options->columns); + + $key_name = $table->keyName() ?? 'id'; + $skip = (array) config('generators.skip_columns', []); + + if ($options->user_type) { + $skip = [...$skip, ...(array) config('generators.auth.skip_columns', [])]; + } + + $fields = []; + + foreach ($table->columns as $column) { + if ($column->name === $key_name) { + continue; + } + + if (in_array($column->name, $skip, true)) { + continue; + } + + if ($options->columns && ! in_array($column->name, $options->columns, true)) { + continue; + } + + if ($field = $this->makeField($table, $column)) { + $fields[$column->name] = $field; + } + } + + if ($options->user_type) { + $fields = $this->addUserTypeFields($table, $fields); + } + + $parent = null; + $parent_relation = null; + + if ($options->parent) { + $parent = $this->resolveParent($table, $fields, $options->parent); + + foreach ($this->relations->hasMany($this->inspector->table($parent->getRelatedTable())) as $has_many) { + if ($has_many->related_table === $table->name && $has_many->foreign_key === $parent->getName()) { + $parent_relation = $has_many->name; + } + } + } + + $belongs_to_many = $this->relations->belongsToMany($table); + + $name_field = $this->nameField($table, $fields, $key_name); + + $translatable = $this->translatableColumns($table, $fields, $options->translatable); + + $form = $this->formColumns($fields, $belongs_to_many, $options->form, $parent); + $index = $this->indexColumns($fields, $belongs_to_many, $options->index, $name_field, $parent); + $show = $this->showColumns($fields, $belongs_to_many, $options->show); + + $this->ensureChosenColumnsExist($fields, $belongs_to_many, [...($options->form ?? []), ...($options->index ?? []), ...($options->show ?? [])]); + + return new ResourceDefinition( + table: $table, + fields: $fields, + key_name: $key_name, + name_field: $name_field, + belongs_to: $this->relations->belongsTo($table), + has_many: $this->relations->hasMany($table), + belongs_to_many: $belongs_to_many, + index_columns: $index, + form_columns: $form, + show_columns: $show, + parent: $parent, + parent_name_field: $parent ? $parent->getRelatedNameField() : null, + media: $options->media, + views: $options->views ?? (string) config('generators.views', 'blade'), + translatable: $translatable, + user_type: $options->user_type, + parent_relation: $parent_relation, + user_guard: $options->user_type ? ($options->user_guard ?? $options->user_type) : null, + ); + } + + /** + * A user type's password and account status, after its name and email. + * + * @param array $fields + * @return array + */ + protected function addUserTypeFields(Table $table, array $fields): array + { + $account = []; + + if ($table->hasColumn('password')) { + $account['password'] = new PasswordField('password', $table->column('password')->nullable); + } + + if ($table->hasColumn('status')) { + $account['status'] = new EnumField('status', [], 'Javaabu\\Auth\\Enums\\UserStatuses', default: 'pending'); + } + + $position = array_search('email', array_keys($fields), true); + $position = $position === false ? 0 : $position + 1; + + return array_slice($fields, 0, $position, true) + $account + array_slice($fields, $position, null, true); + } + + public function makeField(Table $table, Column $column): ?Field + { + $name = $column->name; + $nullable = $column->nullable; + $unique = $column->unique; + $default = $column->default; + + if ($foreign_key = $table->foreignKeyFor($name)) { + return new ForeignKeyField( + $name, + $foreign_key->foreign_table, + $foreign_key->foreign_column, + $nullable, + $default, + $unique, + $this->relatedNameField($foreign_key->foreign_table), + trim((string) config('generators.namespaces.models', 'App\\Models'), '\\'), + ); + } + + $enum_class = $this->enumClass($table, $column); + + if ($enum_class || $column->type === 'enum') { + return new EnumField($name, $column->options, $enum_class, $nullable, $default, $unique); + } + + return match ($column->type) { + 'boolean' => new BooleanField($name, $nullable, $this->booleanDefault($default), $unique), + 'string', 'uuid' => new StringField($name, $nullable, $default, $unique, $column->length ?? 255), + 'text' => new TextField($name, $nullable, $default, $unique, $column->length), + 'tinyint', 'smallint', 'mediumint', 'int', 'bigint' => new IntegerField($name, $nullable, $default, $unique, $column->type, $column->unsigned), + 'decimal' => new DecimalField($name, $nullable, $default, $unique, $column->precision ?? 10, $column->scale ?? 2, $column->unsigned), + 'year' => new YearField($name, $nullable, $default, $unique), + 'datetime', 'timestamp' => new DateTimeField($name, $nullable, $default, $unique), + 'date' => new DateField($name, $nullable, $default, $unique), + 'time' => new TimeField($name, $nullable, $default, $unique), + 'json' => new JsonField($name, $nullable, $default, $unique), + default => null, + }; + } + + /** + * The column that names a record of a table: name, title, or the first string. + */ + public function nameFieldOf(string $table_name): string + { + $table = $this->inspector->table($table_name); + $key = $table->keyName() ?? 'id'; + + foreach ((array) config('generators.name_fields', ['name', 'title']) as $candidate) { + if ($table->column($candidate)?->type === 'string') { + return $candidate; + } + } + + foreach ($table->columns as $column) { + if ($column->type === 'string' && ! $table->foreignKeyFor($column->name) && $column->name !== $key) { + return $column->name; + } + } + + return $key; + } + + protected function relatedNameField(string $table): string + { + if (! $this->inspector->hasTable($table)) { + return 'name'; + } + + return $this->nameFieldOf($table); + } + + protected function enumClass(Table $table, Column $column): ?string + { + $configured = config("generators.enums.{$table->name}.{$column->name}") + ?? ((array) config('generators.enums', []))["{$table->name}.{$column->name}"] + ?? null; + + if (is_string($configured) && $configured !== '') { + return ltrim($configured, '\\'); + } + + return $column->enumClassFromComment(); + } + + protected function booleanDefault(mixed $default): ?bool + { + if ($default === null) { + return null; + } + + return in_array(strtolower((string) $default), ['1', 'true', "b'1'"], true); + } + + /** + * @param array $fields + */ + protected function nameField(Table $table, array $fields, string $key_name): string + { + foreach ((array) config('generators.name_fields', ['name', 'title']) as $candidate) { + if (($fields[$candidate] ?? null) instanceof StringField) { + return $candidate; + } + } + + foreach ($fields as $column => $field) { + if ($field instanceof StringField) { + return $column; + } + } + + return $key_name; + } + + /** + * @param array $fields + */ + protected function resolveParent(Table $table, array $fields, string $parent): ForeignKeyField + { + foreach ($fields as $field) { + if (! $field instanceof ForeignKeyField) { + continue; + } + + $matches = $field->getName() === $parent + || $field->getRelatedTable() === $parent + || $field->getRelationName() === Str::camel($parent) + || $field->getRelatedTable() === Str::plural(Str::snake($parent)); + + if ($matches) { + return $field; + } + } + + throw new InvalidArgumentException("The parent [{$parent}] is not a foreign key of the {$table->name} table."); + } + + /** + * @param array $fields + * @param array|bool|null $choice + * @return array + */ + protected function translatableColumns(Table $table, array $fields, array|bool|null $choice): array + { + $choice ??= config('generators.translatable', 'auto') === 'auto' ? true : (bool) config('generators.translatable'); + + if ($choice === false) { + return []; + } + + if (! $table->hasTranslationColumns()) { + if (is_array($choice) && $choice) { + throw new InvalidArgumentException("The {$table->name} table needs the translations and lang columns to be translatable. Add them with \$table->json('translations')->nullable(); \$table->string('lang')->nullable()->index();"); + } + + return []; + } + + if (! Translations::installed()) { + return []; + } + + if (is_array($choice)) { + return array_values(array_intersect($choice, array_keys($fields))); + } + + $columns = []; + + foreach ($fields as $column => $field) { + if ($field instanceof StringField && ($field->isUnique() || $field->getKind() !== 'text')) { + continue; + } + + if ($field instanceof StringField || $field instanceof TextField) { + $columns[] = $column; + } + } + + return $columns; + } + + /** + * @param array $fields + * @param array $relations + * @param array|null $choice + * @return array + */ + protected function formColumns(array $fields, array $relations, ?array $choice, ?ForeignKeyField $parent): array + { + if ($choice !== null) { + return array_values(array_filter($choice, fn (string $column) => $column !== $parent?->getName())); + } + + $columns = []; + + foreach ($fields as $column => $field) { + if ($parent && $column === $parent->getName()) { + continue; + } + + if ($field->isFormField()) { + $columns[] = $column; + } + } + + foreach ($relations as $relation) { + $columns[] = $relation->name; + } + + return $columns; + } + + /** + * @param array $fields + * @param array $relations + * @param array|null $choice + * @return array + */ + protected function indexColumns(array $fields, array $relations, ?array $choice, string $name_field, ?ForeignKeyField $parent): array + { + if ($choice !== null) { + return array_values(array_unique([$name_field, ...$choice])); + } + + $columns = isset($fields[$name_field]) ? [$name_field] : []; + $limit = (int) config('generators.index_columns', 5); + + foreach ($fields as $column => $field) { + if (count($columns) >= $limit) { + break; + } + + if ($column === $name_field || ($parent && $column === $parent->getName())) { + continue; + } + + if ($field->isIndexColumn()) { + $columns[] = $column; + } + } + + return $columns; + } + + /** + * @param array $fields + * @param array $relations + * @param array|null $choice + * @return array + */ + protected function showColumns(array $fields, array $relations, ?array $choice): array + { + if ($choice !== null) { + return $choice; + } + + return [...array_keys($fields), ...array_map(fn (BelongsToMany $relation) => $relation->name, $relations)]; + } + + /** + * @param array $columns + */ + protected function ensureColumnsExist(Table $table, array $columns): void + { + $missing = array_values(array_filter($columns, fn (string $column) => ! $table->hasColumn($column))); + + if ($missing) { + throw new ColumnDoesNotExistException("The following columns do not exist on the table '{$table->name}': " . implode(', ', $missing)); + } + } + + /** + * @param array $fields + * @param array $relations + * @param array $chosen + */ + protected function ensureChosenColumnsExist(array $fields, array $relations, array $chosen): void + { + $known = [...array_keys($fields), ...array_map(fn (BelongsToMany $relation) => $relation->name, $relations)]; + + $missing = array_values(array_unique(array_diff($chosen, $known))); + + if ($missing) { + throw new ColumnDoesNotExistException('These fields are not columns or many-to-many relations of the resource: ' . implode(', ', $missing)); + } + } +} diff --git a/src/Schema/Column.php b/src/Schema/Column.php new file mode 100644 index 0000000..4d09722 --- /dev/null +++ b/src/Schema/Column.php @@ -0,0 +1,42 @@ + $options + */ + public function __construct( + public string $name, + public string $type, + public string $full_type = '', + public bool $nullable = false, + public mixed $default = null, + public bool $auto_increment = false, + public bool $unique = false, + public ?string $comment = null, + public ?int $length = null, + public ?int $precision = null, + public ?int $scale = null, + public bool $unsigned = false, + public array $options = [], + ) {} + + public function hasEnumClassComment(): bool + { + return str_starts_with((string) $this->comment, 'enum:'); + } + + public function enumClassFromComment(): ?string + { + if (! $this->hasEnumClassComment()) { + return null; + } + + return trim(substr((string) $this->comment, 5)) ?: null; + } +} diff --git a/src/Schema/ForeignKey.php b/src/Schema/ForeignKey.php new file mode 100644 index 0000000..10484f8 --- /dev/null +++ b/src/Schema/ForeignKey.php @@ -0,0 +1,13 @@ + + */ + public function belongsTo(Table $table): array + { + $relations = []; + + foreach ($table->foreign_keys as $foreign_key) { + if ($this->isIgnored($foreign_key->foreign_table)) { + continue; + } + + $relations[] = new BelongsTo( + Naming::belongsTo($foreign_key->column, $foreign_key->foreign_table), + $foreign_key->foreign_table, + $foreign_key->column, + $foreign_key->foreign_column, + ); + } + + return $relations; + } + + /** + * @return array + */ + public function hasMany(Table $table): array + { + $relations = []; + $names = []; + + foreach ($this->inspector->tableNames() as $other_name) { + if ($other_name === $table->name || $this->isIgnored($other_name)) { + continue; + } + + $other = $this->inspector->table($other_name); + + if ($this->isPivotTable($other)) { + continue; + } + + $keys = array_values(array_filter( + $other->foreign_keys, + fn (ForeignKey $foreign_key) => $foreign_key->foreign_table === $table->name + )); + + foreach ($keys as $foreign_key) { + $name = StringCaser::pluralCamel($other_name); + + // a second key to the same table: name it after the key (e.g. reviewer_id gives reviewedPosts) + if (count($keys) > 1 || in_array($name, $names, true)) { + $name = Str::camel(Str::snake(Naming::belongsTo($foreign_key->column, $table->name)) . '_' . $other_name); + } + + $names[] = $name; + + $relations[] = new HasMany( + $name, + $other_name, + $foreign_key->column, + $table->name, + $foreign_key->foreign_column, + ); + } + } + + return $relations; + } + + /** + * @return array + */ + public function belongsToMany(Table $table): array + { + $relations = []; + + foreach ($this->inspector->tableNames() as $pivot_name) { + if ($pivot_name === $table->name || $this->isIgnored($pivot_name)) { + continue; + } + + $pivot = $this->inspector->table($pivot_name); + + if (! $this->isPivotTable($pivot)) { + continue; + } + + [$first, $second] = $pivot->foreign_keys; + + foreach ([[$first, $second], [$second, $first]] as [$own, $other]) { + if ($own->foreign_table !== $table->name) { + continue; + } + + $relations[] = new BelongsToMany( + StringCaser::pluralCamel($other->foreign_table), + $other->foreign_table, + $pivot_name, + $own->column, + $other->column, + $table->name, + $this->pivotColumns($pivot), + $pivot->hasTimestamps(), + ); + + break; + } + } + + return $relations; + } + + /** + * A table with two foreign keys to two different tables, and no data + * columns of its own except id and timestamps (or any other extra pivot + * columns, when it is named after both tables). + */ + public function isPivotTable(Table $table): bool + { + if (count($table->foreign_keys) !== 2) { + return false; + } + + [$first, $second] = $table->foreign_keys; + + if ($first->foreign_table === $second->foreign_table) { + return false; + } + + // Eloquent pivots join on the related tables' keys + if (! $this->referencesKey($first) || ! $this->referencesKey($second)) { + return false; + } + + $extra = $this->pivotColumns($table); + + if (! $extra) { + return true; + } + + $tables = [Str::singular($first->foreign_table), Str::singular($second->foreign_table)]; + sort($tables); + + return $table->name === implode('_', $tables); + } + + /** + * @return array + */ + protected function pivotColumns(Table $table): array + { + $keys = array_map(fn (ForeignKey $foreign_key) => $foreign_key->column, $table->foreign_keys); + + return array_values(array_diff(array_keys($table->columns), $keys, self::PIVOT_BOOKKEEPING_COLUMNS)); + } + + protected function referencesKey(ForeignKey $foreign_key): bool + { + if (! $this->inspector->hasTable($foreign_key->foreign_table)) { + return $foreign_key->foreign_column === 'id'; + } + + return $this->inspector->table($foreign_key->foreign_table)->keyName() === $foreign_key->foreign_column; + } + + protected function isIgnored(string $table): bool + { + return in_array($table, (array) config('generators.ignored_tables', []), true); + } +} diff --git a/src/Schema/Relations/BelongsTo.php b/src/Schema/Relations/BelongsTo.php new file mode 100644 index 0000000..8272432 --- /dev/null +++ b/src/Schema/Relations/BelongsTo.php @@ -0,0 +1,39 @@ +relatedModel()}::class"]; + + $conventional_key = Str::snake($this->name) . '_id'; + + if ($this->foreign_key !== $conventional_key || $this->owner_key !== 'id') { + $arguments[] = "'{$this->foreign_key}'"; + } + + if ($this->owner_key !== 'id') { + $arguments[] = "'{$this->owner_key}'"; + } + + return 'belongsTo(' . implode(', ', $arguments) . ')'; + } +} diff --git a/src/Schema/Relations/BelongsToMany.php b/src/Schema/Relations/BelongsToMany.php new file mode 100644 index 0000000..9d63531 --- /dev/null +++ b/src/Schema/Relations/BelongsToMany.php @@ -0,0 +1,65 @@ + $pivot_columns extra pivot columns + */ + public function __construct( + string $name, + string $related_table, + public readonly string $pivot_table, + public readonly string $foreign_pivot_key, + public readonly string $related_pivot_key, + public readonly string $parent_table, + public readonly array $pivot_columns = [], + public readonly bool $pivot_timestamps = false, + ) { + parent::__construct($name, $related_table); + } + + public function type(): string + { + return 'BelongsToMany'; + } + + public function definition(): string + { + $arguments = ["{$this->relatedModel()}::class"]; + + $tables = [Str::singular($this->parent_table), Str::singular($this->related_table)]; + sort($tables); + + $conventional_table = implode('_', $tables); + $conventional_foreign = Str::singular($this->parent_table) . '_id'; + $conventional_related = Str::singular($this->related_table) . '_id'; + + $keys_conventional = $this->foreign_pivot_key === $conventional_foreign + && $this->related_pivot_key === $conventional_related; + + if ($this->pivot_table !== $conventional_table || ! $keys_conventional) { + $arguments[] = "'{$this->pivot_table}'"; + } + + if (! $keys_conventional) { + $arguments[] = "'{$this->foreign_pivot_key}'"; + $arguments[] = "'{$this->related_pivot_key}'"; + } + + $definition = 'belongsToMany(' . implode(', ', $arguments) . ')'; + + if ($this->pivot_columns) { + $definition .= "->withPivot('" . implode("', '", $this->pivot_columns) . "')"; + } + + if ($this->pivot_timestamps) { + $definition .= '->withTimestamps()'; + } + + return $definition; + } +} diff --git a/src/Schema/Relations/HasMany.php b/src/Schema/Relations/HasMany.php new file mode 100644 index 0000000..5e18951 --- /dev/null +++ b/src/Schema/Relations/HasMany.php @@ -0,0 +1,40 @@ +relatedModel()}::class"]; + + $conventional_key = Str::singular($this->parent_table) . '_id'; + + if ($this->foreign_key !== $conventional_key || $this->local_key !== 'id') { + $arguments[] = "'{$this->foreign_key}'"; + } + + if ($this->local_key !== 'id') { + $arguments[] = "'{$this->local_key}'"; + } + + return 'hasMany(' . implode(', ', $arguments) . ')'; + } +} diff --git a/src/Schema/Relations/Relation.php b/src/Schema/Relations/Relation.php new file mode 100644 index 0000000..8afa23b --- /dev/null +++ b/src/Schema/Relations/Relation.php @@ -0,0 +1,25 @@ +related_table); + } + + abstract public function type(): string; + + /** + * The relation method body, e.g. belongsTo(Category::class). + */ + abstract public function definition(): string; +} diff --git a/src/Schema/SchemaInspector.php b/src/Schema/SchemaInspector.php new file mode 100644 index 0000000..abcad9a --- /dev/null +++ b/src/Schema/SchemaInspector.php @@ -0,0 +1,402 @@ + */ + protected array $tables = []; + + /** @var array|null */ + protected ?array $table_names = null; + + public function __construct( + protected DatabaseManager $db, + protected ?string $connection = null, + ) {} + + public function connection(): Connection + { + return $this->db->connection($this->connection); + } + + public function driver(): string + { + return $this->connection()->getDriverName(); + } + + public function hasTable(string $table): bool + { + return in_array($table, $this->tableNames(), true); + } + + /** + * Every table in the database, without the framework's own tables. + * + * @return array + */ + public function tableNames(): array + { + if ($this->table_names !== null) { + return $this->table_names; + } + + $schema = $this->connection()->getSchemaBuilder(); + $tables = $schema->getTables($this->schemaName()); + + $prefix = $this->connection()->getTablePrefix(); + + $names = []; + + foreach ($tables as $table) { + $name = $table['name']; + + if ($prefix && str_starts_with($name, $prefix)) { + $name = substr($name, strlen($prefix)); + } + + if (str_starts_with($name, 'sqlite_')) { + continue; + } + + $names[] = $name; + } + + sort($names); + + return $this->table_names = array_values(array_unique($names)); + } + + public function table(string $name): Table + { + if (isset($this->tables[$name])) { + return $this->tables[$name]; + } + + if (! in_array($this->driver(), ['mysql', 'mariadb', 'pgsql', 'sqlite'], true)) { + throw new UnsupportedDbDriverException("This database driver is not supported: {$this->driver()}"); + } + + $schema = $this->connection()->getSchemaBuilder(); + + if (! $schema->hasTable($name)) { + throw new TableDoesNotExistException("Table '{$name}' not found!"); + } + + $indexes = $schema->getIndexes($name); + + $unique_columns = []; + $primary_key = []; + + foreach ($indexes as $index) { + if ($index['primary']) { + $primary_key = $index['columns']; + + continue; + } + + if ($index['unique'] && count($index['columns']) === 1) { + $unique_columns[] = $index['columns'][0]; + } + } + + $check_options = $this->checkConstraintOptions($name); + + $columns = []; + + foreach ($schema->getColumns($name) as $definition) { + $column = $this->normalize($definition, in_array($definition['name'], $unique_columns, true), $check_options); + + $columns[$column->name] = $column; + } + + $foreign_keys = []; + + foreach ($schema->getForeignKeys($name) as $foreign_key) { + if (count($foreign_key['columns']) !== 1) { + continue; + } + + $foreign_table = $foreign_key['foreign_table']; + $prefix = $this->connection()->getTablePrefix(); + + if ($prefix && str_starts_with($foreign_table, $prefix)) { + $foreign_table = substr($foreign_table, strlen($prefix)); + } + + $foreign_keys[] = new ForeignKey( + $foreign_key['columns'][0], + $foreign_table, + $foreign_key['foreign_columns'][0] ?? 'id', + isset($foreign_key['on_delete']) ? strtolower((string) $foreign_key['on_delete']) : null, + ); + } + + return $this->tables[$name] = new Table($name, $columns, $foreign_keys, $primary_key); + } + + /** + * Forget the tables read so far. + */ + public function flush(): void + { + $this->tables = []; + $this->table_names = null; + } + + /** + * @param array $definition + * @param array> $check_options + */ + protected function normalize(array $definition, bool $unique, array $check_options): Column + { + $name = $definition['name']; + $type_name = strtolower((string) $definition['type_name']); + $full_type = strtolower((string) $definition['type']); + + [$type, $length, $precision, $scale, $options] = $this->resolveType($type_name, $full_type); + + if (isset($check_options[$name]) && in_array($type, ['string', 'text'], true)) { + $type = 'enum'; + $options = $check_options[$name]; + } + + return new Column( + name: $name, + type: $type, + full_type: $full_type, + nullable: (bool) $definition['nullable'], + default: $this->normalizeDefault($definition['default'] ?? null), + auto_increment: (bool) $definition['auto_increment'], + unique: $unique, + comment: ($definition['comment'] ?? null) ?: null, + length: $length, + precision: $precision, + scale: $scale, + unsigned: str_contains($full_type, 'unsigned'), + options: $options, + ); + } + + /** + * @return array{0: string, 1: ?int, 2: ?int, 3: ?int, 4: array} + */ + protected function resolveType(string $type_name, string $full_type): array + { + $arguments = $this->typeArguments($full_type); + + if ($type_name === 'enum' || $type_name === 'set') { + preg_match_all("/'((?:[^']|'')*)'/", $full_type, $matches); + + return ['enum', null, null, null, array_map(fn (string $option) => str_replace("''", "'", $option), $matches[1])]; + } + + if ($type_name === 'tinyint' && str_starts_with($full_type, 'tinyint(1)') && config('generators.tinyint1_to_bool', true)) { + return ['boolean', null, null, null, []]; + } + + if (in_array($type_name, ['bool', 'boolean'], true)) { + return ['boolean', null, null, null, []]; + } + + if (in_array($type_name, ['varchar', 'char', 'bpchar', 'character varying', 'character', 'nvarchar', 'nchar', 'string'], true)) { + return ['string', $arguments[0] ?? 255, null, null, []]; + } + + if (in_array($type_name, ['tinytext', 'text', 'mediumtext', 'longtext', 'clob'], true)) { + $lengths = ['tinytext' => 255, 'text' => 65535, 'mediumtext' => 16777215, 'longtext' => 4294967295]; + + return ['text', $lengths[$type_name] ?? null, null, null, []]; + } + + if (in_array($type_name, ['json', 'jsonb'], true)) { + return ['json', null, null, null, []]; + } + + if (in_array($type_name, ['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'int2', 'int4', 'int8', 'serial', 'bigserial', 'smallserial'], true)) { + $aliases = [ + 'int2' => 'smallint', + 'int4' => 'int', + 'integer' => 'int', + 'serial' => 'int', + 'int8' => 'bigint', + 'bigserial' => 'bigint', + 'smallserial' => 'smallint', + ]; + + return [$aliases[$type_name] ?? $type_name, null, null, null, []]; + } + + if (in_array($type_name, ['decimal', 'numeric', 'double', 'float', 'real', 'float4', 'float8', 'double precision'], true)) { + $precision = $arguments[0] ?? null; + $scale = $arguments[1] ?? null; + + if ($precision === null) { + // SQLite and floats keep no precision + $precision = 10; + $scale = 2; + } + + return ['decimal', null, $precision, $scale ?? 0, []]; + } + + if ($type_name === 'year') { + return ['year', null, null, null, []]; + } + + if (in_array($type_name, ['datetime', 'timestamp', 'timestamptz', 'datetime2', 'datetimeoffset'], true)) { + return [$type_name === 'datetime' ? 'datetime' : 'timestamp', null, null, null, []]; + } + + if ($type_name === 'date') { + return ['date', null, null, null, []]; + } + + if (in_array($type_name, ['time', 'timetz'], true)) { + return ['time', null, null, null, []]; + } + + if ($type_name === 'uuid') { + return ['uuid', 36, null, null, []]; + } + + return [$type_name, null, null, null, []]; + } + + /** + * The numbers in a type's parentheses: decimal(14,2) gives [14, 2]. + * + * @return array + */ + protected function typeArguments(string $full_type): array + { + if (! preg_match('/\(([\d\s,]+)\)/', $full_type, $matches)) { + return []; + } + + return array_map('intval', array_map('trim', explode(',', $matches[1]))); + } + + protected function normalizeDefault(mixed $default): mixed + { + if ($default === null) { + return null; + } + + if (! is_string($default)) { + return $default; + } + + $value = $default; + + // PostgreSQL: 'draft'::character varying + if (preg_match("/^'(.*)'::[\w\s]+$/s", $value, $matches)) { + return str_replace("''", "'", $matches[1]); + } + + if (preg_match("/^'(.*)'$/s", $value, $matches)) { + return str_replace("''", "'", $matches[1]); + } + + if (strtoupper($value) === 'NULL') { + return null; + } + + return $value; + } + + /** + * Options of enum columns that the driver stores as a check constraint. + * + * @return array> + */ + protected function checkConstraintOptions(string $table): array + { + return match ($this->driver()) { + 'sqlite' => $this->sqliteCheckOptions($table), + 'pgsql' => $this->postgresCheckOptions($table), + default => [], + }; + } + + /** + * @return array> + */ + protected function sqliteCheckOptions(string $table): array + { + $prefixed = $this->connection()->getTablePrefix() . $table; + + $row = $this->connection()->selectOne("select sql from sqlite_master where type = 'table' and name = ?", [$prefixed]); + + if (! $row || ! $row->sql) { + return []; + } + + preg_match_all('/check\s*\(\s*["`]?(\w+)["`]?\s+in\s*\((.*?)\)\s*\)/is', $row->sql, $matches, PREG_SET_ORDER); + + $options = []; + + foreach ($matches as $match) { + preg_match_all("/'((?:[^']|'')*)'/", $match[2], $values); + + $options[$match[1]] = array_map(fn (string $option) => str_replace("''", "'", $option), $values[1]); + } + + return $options; + } + + /** + * @return array> + */ + protected function postgresCheckOptions(string $table): array + { + $prefixed = $this->connection()->getTablePrefix() . $table; + + $rows = $this->connection()->select( + "select pg_get_constraintdef(c.oid) as definition + from pg_constraint c + join pg_class t on c.conrelid = t.oid + join pg_namespace n on n.oid = t.relnamespace + where c.contype = 'c' and t.relname = ? and n.nspname = current_schema()", + [$prefixed] + ); + + $options = []; + + foreach ($rows as $row) { + $definition = (string) $row->definition; + + if (! Str::contains($definition, ['ANY', ' IN '])) { + continue; + } + + if (! preg_match('/\(+\s*"?(\w+)"?\)?(?:::\w+(?:\s\w+)?)?\s*(?:=\s*ANY|IN)/i', $definition, $column)) { + continue; + } + + preg_match_all("/'((?:[^']|'')*)'/", $definition, $values); + + $options[$column[1]] = array_map(fn (string $option) => str_replace("''", "'", $option), $values[1]); + } + + return $options; + } + + protected function schemaName(): ?string + { + return match ($this->driver()) { + 'mysql', 'mariadb' => $this->connection()->getDatabaseName(), + default => null, + }; + } +} diff --git a/src/Schema/Table.php b/src/Schema/Table.php new file mode 100644 index 0000000..fbccfdf --- /dev/null +++ b/src/Schema/Table.php @@ -0,0 +1,85 @@ + $columns keyed by name, in table order + * @param array $foreign_keys single-column foreign keys + * @param array $primary_key + */ + public function __construct( + public string $name, + public array $columns, + public array $foreign_keys = [], + public array $primary_key = [], + ) {} + + public function hasColumn(string $column): bool + { + return array_key_exists($column, $this->columns); + } + + public function column(string $column): ?Column + { + return $this->columns[$column] ?? null; + } + + public function foreignKeyFor(string $column): ?ForeignKey + { + foreach ($this->foreign_keys as $foreign_key) { + if ($foreign_key->column === $column) { + return $foreign_key; + } + } + + return null; + } + + /** + * The auto-incrementing key, or the single-column primary key. + */ + public function keyName(): ?string + { + foreach ($this->columns as $column) { + if ($column->auto_increment) { + return $column->name; + } + } + + if (count($this->primary_key) === 1) { + return $this->primary_key[0]; + } + + return null; + } + + public function hasTimestamps(): bool + { + return $this->isDateTimeColumn('created_at') && $this->isDateTimeColumn('updated_at'); + } + + public function hasSoftDeletes(): bool + { + return $this->isDateTimeColumn('deleted_at'); + } + + /** + * Whether the table has javaabu/translatable's JSON columns. + */ + public function hasTranslationColumns(): bool + { + return $this->hasColumn('translations') && $this->hasColumn('lang'); + } + + protected function isDateTimeColumn(string $name): bool + { + $column = $this->column($name); + + return $column !== null && in_array($column->type, ['datetime', 'timestamp'], true); + } +} diff --git a/src/Support/Imports.php b/src/Support/Imports.php new file mode 100644 index 0000000..1f7b747 --- /dev/null +++ b/src/Support/Imports.php @@ -0,0 +1,88 @@ + class => alias */ + protected array $classes = []; + + /** + * @param array $classes + */ + public function __construct(array $classes = [], protected ?string $namespace = null) + { + foreach ($classes as $class) { + $this->add($class); + } + } + + /** + * Import a class and return the name to use for it in the code. + */ + public function add(string $class): string + { + $class = ltrim($class, '\\'); + $basename = class_basename($class); + + if (isset($this->classes[$class])) { + return $this->classes[$class]; + } + + // a different class with the same basename: use the fully qualified name + if (in_array($basename, $this->classes, true)) { + return '\\' . $class; + } + + $this->classes[$class] = $basename; + + return $basename; + } + + public function has(string $class): bool + { + return isset($this->classes[ltrim($class, '\\')]); + } + + public function merge(Imports $imports): static + { + foreach (array_keys($imports->classes) as $class) { + $this->add($class); + } + + return $this; + } + + /** + * @return array + */ + public function all(): array + { + $classes = array_keys($this->classes); + + if ($this->namespace) { + $classes = array_filter( + $classes, + fn (string $class) => ! (str_starts_with($class, $this->namespace . '\\') && substr_count($class, '\\') === substr_count($this->namespace, '\\') + 1) + ); + } + + usort($classes, fn (string $a, string $b) => strcasecmp($a, $b)); + + return array_values($classes); + } + + public function render(): string + { + $classes = $this->all(); + + if (! $classes) { + return ''; + } + + return implode("\n", array_map(fn (string $class) => "use {$class};", $classes)); + } +} diff --git a/src/Support/Naming.php b/src/Support/Naming.php new file mode 100644 index 0000000..589e8e7 --- /dev/null +++ b/src/Support/Naming.php @@ -0,0 +1,27 @@ + $match[0], $matches[0]); + $uses[] = $statement; + + usort($uses, fn (string $a, string $b) => strcasecmp(rtrim($a, ';'), rtrim($b, ';'))); + + $first = $matches[0][0][1]; + $last = end($matches[0]); + $end = $last[1] + strlen($last[0]); + + return substr($code, 0, $first) . implode("\n", $uses) . substr($code, $end); + } + + if (preg_match('/^namespace [^;]+;$/m', $code, $match, PREG_OFFSET_CAPTURE)) { + $end = $match[0][1] + strlen($match[0][0]); + + return substr($code, 0, $end) . "\n\n{$statement}" . substr($code, $end); + } + + return preg_replace('/^<\?php\s*/', " $line === '' ? '' : $body_indent . $line, + explode("\n", trim($statements, "\n")) + )); + + if (str_contains($body, trim($statements))) { + return $code; + } + + $new_body = trim($trimmed) === '' + ? "\n{$statements}\n{$indent}" + : "{$trimmed}\n\n{$statements}\n{$indent}"; + + return substr($code, 0, $open + 1) . $new_body . substr($code, $close); + } + + /** + * Add code before a marker comment, or at the end of a closure group + * that contains the marker's text. + */ + public static function insertBeforeMarker(string $code, string $marker, string $content): string + { + $position = strpos($code, $marker); + + if ($position === false) { + throw new RuntimeException("The marker [{$marker}] was not found."); + } + + $line_start = strrpos(substr($code, 0, $position), "\n") + 1; + $indent = substr($code, $line_start, $position - $line_start); + + $content = implode("\n", array_map( + fn (string $line) => $line === '' ? '' : $indent . $line, + explode("\n", trim($content, "\n")) + )); + + if (str_contains($code, trim($content))) { + return $code; + } + + return substr($code, 0, $line_start) . $content . "\n\n" . substr($code, $line_start); + } + + /** + * Add an entry to an array under a key, e.g. 'customer' => [...] to 'guards' + * in config/auth.php, unless the array has the entry's key already. + */ + public static function addArrayEntry(string $code, string $array_key, string $entry_key, string $entry): string + { + if (! preg_match("/(['\"]){$array_key}\\1\\s*=>\\s*\\[/", $code, $match, PREG_OFFSET_CAPTURE)) { + throw new RuntimeException("The [{$array_key}] array was not found."); + } + + $open = $match[0][1] + strlen($match[0][0]) - 1; + $close = static::matchingBracket($code, $open); + + $body = substr($code, $open + 1, $close - $open - 1); + + if (preg_match("/(['\"])" . preg_quote($entry_key, '/') . '\\1\\s*=>/', $body)) { + return $code; + } + + $line_start = strrpos(substr($code, 0, $match[0][1]), "\n") + 1; + $indent = preg_replace('/\S.*$/s', '', substr($code, $line_start, $match[0][1] - $line_start)); + $item_indent = $indent . ' '; + + $entry = implode("\n", array_map( + fn (string $line) => $line === '' ? '' : $item_indent . $line, + explode("\n", trim($entry)) + )); + + $trimmed = rtrim($body); + + $new_body = trim($trimmed) === '' + ? "\n{$entry}\n{$indent}" + : $trimmed . (str_ends_with($trimmed, ',') ? '' : ',') . "\n\n{$entry}\n{$indent}"; + + return substr($code, 0, $open + 1) . $new_body . substr($code, $close); + } + + protected static function matchingBracket(string $code, int $open): int + { + $depth = 0; + $length = strlen($code); + + for ($i = $open; $i < $length; $i++) { + $char = $code[$i]; + + if ($char === '\'' || $char === '"') { + $i = static::skipString($code, $i); + + continue; + } + + if ($char === '[') { + $depth++; + } + + if ($char === ']') { + $depth--; + + if ($depth === 0) { + return $i; + } + } + } + + throw new RuntimeException('Unbalanced brackets.'); + } + + protected static function matchingBrace(string $code, int $open): int + { + $depth = 0; + $length = strlen($code); + + for ($i = $open; $i < $length; $i++) { + $char = $code[$i]; + + if ($char === '\'' || $char === '"') { + $i = static::skipString($code, $i); + + continue; + } + + if ($char === '{') { + $depth++; + } + + if ($char === '}') { + $depth--; + + if ($depth === 0) { + return $i; + } + } + } + + throw new RuntimeException('Unbalanced braces.'); + } + + protected static function skipString(string $code, int $start): int + { + $quote = $code[$start]; + $length = strlen($code); + + for ($i = $start + 1; $i < $length; $i++) { + if ($code[$i] === '\\') { + $i++; + + continue; + } + + if ($code[$i] === $quote) { + return $i; + } + } + + return $length; + } +} diff --git a/src/Support/Stub.php b/src/Support/Stub.php new file mode 100644 index 0000000..379081f --- /dev/null +++ b/src/Support/Stub.php @@ -0,0 +1,121 @@ + */ + protected array $values = []; + + protected ?string $name = null; + + public function __construct( + protected string $stub, + ) {} + + public static function make(string $stub): static + { + return new static($stub); + } + + /** + * Replace the name placeholders with the cases of this name. + */ + public function names(string $name): static + { + $this->name = $name; + + return $this; + } + + /** + * @param array $values + */ + public function with(array $values): static + { + foreach ($values as $key => $value) { + $this->values[$key] = (string) $value; + } + + return $this; + } + + public function render(): string + { + /** @var StubRenderer $renderer */ + $renderer = app(StubRenderer::class); + + $template = $renderer->loadStub("generators::{$this->stub}"); + + if ($this->name !== null) { + $template = $renderer->replaceNames($this->name, $template); + } + + return static::fill($template, $this->values); + } + + /** + * @param array $values + */ + public static function fill(string $template, array $values): string + { + $template = str_replace("\r\n", "\n", $template); + + $lines = explode("\n", $template); + $output = []; + + foreach ($lines as $line) { + if (preg_match('/^(\s*)\{\{ (\w+) \}\}\s*$/', $line, $matches) && array_key_exists($matches[2], $values)) { + $value = $values[$matches[2]]; + + if ($value === '') { + continue; + } + + foreach (explode("\n", $value) as $value_line) { + $output[] = $value_line === '' ? '' : $matches[1] . $value_line; + } + + continue; + } + + $output[] = preg_replace_callback( + '/\{\{ (\w+) \}\}/', + fn (array $match) => array_key_exists($match[1], $values) ? $values[$match[1]] : $match[0], + $line + ); + } + + $rendered = implode("\n", $output); + + // collapse blank lines left by empty blocks + $rendered = preg_replace("/\n{3,}/", "\n\n", $rendered); + $rendered = preg_replace("/(\{\n)\n+/", '$1', $rendered); + $rendered = preg_replace("/\n\n+(\s*\})/", "\n$1", $rendered); + + return $rendered; + } + + /** + * Indent every non-empty line. + */ + public static function indent(string $code, int $levels = 1): string + { + $spaces = str_repeat(' ', $levels); + + return implode("\n", array_map( + fn (string $line) => $line === '' ? '' : $spaces . $line, + explode("\n", $code) + )); + } +} diff --git a/src/Support/TableProperties.php b/src/Support/TableProperties.php deleted file mode 100644 index 18bb51c..0000000 --- a/src/Support/TableProperties.php +++ /dev/null @@ -1,56 +0,0 @@ -fields = $fields; - $this->key_name = $key_name; - $this->soft_deletes = $soft_deletes; - $this->timestamps = $timestamps; - } - - /** - * Get the table key name - */ - public function getKeyName(): string - { - return $this->key_name; - } - - /** - * @return array - */ - public function getFields(): array - { - return $this->fields; - } - - /** - * @return bool - */ - public function hasSoftDeletes(): bool - { - return $this->soft_deletes; - } - - /** - * @return bool - */ - public function hasTimestamps(): bool - { - return $this->timestamps; - } - - -} diff --git a/src/Support/Translations.php b/src/Support/Translations.php new file mode 100644 index 0000000..eb61ee9 --- /dev/null +++ b/src/Support/Translations.php @@ -0,0 +1,29 @@ +authorizeResource({{singularStudly}}::class); - } - - /** - * Initialize orderbys - */ - protected static function initOrderbys() - { - static::$orderbys = [ - ]; - } - - /** - * Display a listing of the resource. - */ - public function index(Request $request, bool $trashed = false) - { - $title = $trashed ? __('All Deleted {{pluralTitle}}') : __('All {{pluralTitle}}'); - $orderby = $this->getOrderBy($request, 'created_at'); - $order = $this->getOrder($request, 'created_at', $orderby); - $per_page = $this->getPerPage($request); - - ${{pluralSnake}} = {{singularStudly}}::orderBy($orderby, $order); - - $search = null; - if ($search = $request->input('search')) { - ${{pluralSnake}}->search($search); - $title = __('{{pluralTitle}} matching \':search\'', ['search' => $search]); - } - - if ($status = $request->input('status')) { - ${{pluralSnake}}->whereStatus($status); - } - - if ($request->filled('locked_out')) { - if ($request->input('locked_out')) { - ${{pluralSnake}}->lockedOut(); - } else { - ${{pluralSnake}}->notLockedOut(); - } - } - - if ($date_field = $request->input('date_field')) { - ${{pluralSnake}}->dateBetween($date_field, $request->input('date_from'), $request->input('date_to')); - } - - if ($trashed) { - ${{pluralSnake}}->onlyTrashed(); - } - // eager loads - if ($request->download) { - return (new {{pluralStudly}}Export(${{pluralSnake}}))->download('{{pluralKebab}}.xlsx'); - } - - ${{pluralSnake}} = ${{pluralSnake}}->paginate($per_page) - {{appendSpaces}}->appends($request->except('page')); - - return view('admin.{{pluralKebab}}.index', compact('{{pluralSnake}}', 'title', 'per_page', 'search', 'trashed')); - } - - /** - * Show the form for creating a new resource. - */ - public function create(Request $request) - { - return view('admin.{{pluralKebab}}.create'); - } - - /** - * Store a newly created resource in storage. - */ - public function store({{pluralStudly}}Request $request) - { - ${{singularSnake}} = new {{singularStudly}}({{requestFillable}}); - ${{singularSnake}}->email = $request->input('email'); - ${{singularSnake}}->setEmailVerificationStatus($request->input('email_verified', false)); - // booleans - // foreign keys - - $password = $request->input('password'); - - if ($password) { - ${{singularSnake}}->password = $password; - ${{singularSnake}}->require_password_update = $request->input('require_password_update', false); - } - - if ($request->user()->can('approve', {{singularStudly}}::class)) { - ${{singularSnake}}->approve(); - } else { - ${{singularSnake}}->markAsPending(); - } - - ${{singularSnake}}->save(); - - ${{singularSnake}}->updateSingleMedia('avatar', $request); - - if ($password && $request->send_password) { - ${{singularSnake}}->notify(new NewPasswordNotification($password)); - } - - $this->flashSuccessMessage(); - - return redirect()->action([{{pluralStudly}}Controller::class, 'edit'], ${{singularSnake}}); - } - - /** - * Display the specified resource. - */ - public function show({{singularStudly}} ${{singularSnake}}) - { - return view('admin.{{pluralKebab}}.show', compact('{{singularSnake}}')); - } - - /** - * Show the form for editing the specified resource. - */ - public function edit({{singularStudly}} ${{singularSnake}}) - { - return view('admin.{{pluralKebab}}.edit', compact('{{singularSnake}}')); - } - - /** - * Update the specified resource in storage. - */ - public function update({{pluralStudly}}Request $request, {{singularStudly}} ${{singularSnake}}) - { - if ($action = $request->input('action')) { - switch ($action) { - case 'resend_verification': - $this->authorize('resendVerification', ${{singularSnake}}); - ${{singularSnake}}->resendEmailVerification(); - - flash_push('alerts', [ - 'text' => __('Email verification resent successfully.'), - 'type' => 'success', - 'title' => __('Success!'), - ]); - break; - - case 'approve': - $this->authorize('approve', {{singularStudly}}::class); - ${{singularSnake}}->approve(); - break; - - case 'ban': - $this->authorize('approve', {{singularStudly}}::class); - ${{singularSnake}}->ban(); - break; - - case 'mark_pending': - $this->authorize('approve', {{singularStudly}}::class); - ${{singularSnake}}->markAsPending(); - break; - - case 'update_password': - // update password - if ($password = $request->input('password')) { - ${{singularSnake}}->password = $password; - - if ($request->input('require_password_update')) { - ${{singularSnake}}->require_password_update = true; - } else { - ${{singularSnake}}->require_password_update = false; - } - - ${{singularSnake}}->resetLoginAttempts(); - - if ($password && $request->send_password) { - ${{singularSnake}}->notify(new NewPasswordNotification($password)); - } - - flash_push('alerts', [ - 'text' => __('Password changed successfully'), - 'type' => 'success', - 'title' => __('Success!'), - ]); - } - break; - } - } else { - // fill update - // booleans update - // foreign keys update - - if ($email = $request->input('email')) { - ${{singularSnake}}->email = $email; - } - - if ($request->filled('email_verified')) { - ${{singularSnake}}->setEmailVerificationStatus($request->email_verified); - } - - ${{singularSnake}}->updateSingleMedia('avatar', $request); - } - - ${{singularSnake}}->save(); - - $this->flashSuccessMessage(); - - return redirect()->action([{{pluralStudly}}Controller::class, 'edit'], ${{singularSnake}}); - } - - /** - * Remove the specified resource from storage. - */ - public function destroy({{singularStudly}} ${{singularSnake}}, Request $request) - { - ${{singularSnake}}->delete(); - - if ($request->expectsJson()) { - return response()->json(true); - } - - return redirect()->action([{{pluralStudly}}Controller::class, 'index']); - } - - /** - * Display a listing of the deleted resources. - */ - public function trash(Request $request) - { - $this->authorize('viewTrash', {{singularStudly}}::class); - - return $this->index($request, true); - } - - /** - * Force delete the resource - */ - public function forceDelete({{singularStudly}} ${{singularSnake}}, Request $request) - { - // make sure it is trashed - if (! ${{singularSnake}}->trashed()) { - abort(404); - } - - $this->authorize('forceDelete', ${{singularSnake}}); - - ${{singularSnake}}->forceDelete(); - - if ($request->expectsJson()) { - return response()->json(true); - } - - return redirect()->action([{{pluralStudly}}Controller::class, 'trash']); - } - - /** - * Restore deleted resource - */ - public function restore({{singularStudly}} ${{singularSnake}}, Request $request) - { - // make sure it is trashed - if (! ${{singularSnake}}->trashed()) { - abort(404); - } - - $this->authorize('restore', ${{singularSnake}}); - - ${{singularSnake}}->restore(); - - if ($request->expectsJson()) { - return response()->json(true); - } - - return redirect()->action([{{pluralStudly}}Controller::class, 'trash']); - } - - /** - * Perform bulk action on the resource - */ - public function bulk(Request $request) - { - $this->authorize('viewAny', {{singularStudly}}::class); - - $this->validate($request, [ - 'action' => 'required|in:delete,approve,ban', - '{{pluralSnake}}' => 'required|array', - '{{pluralSnake}}.*' => 'exists:{{pluralSnake}},{{keyName}}', - ]); - - $action = $request->input('action'); - $ids = $request->input('{{pluralSnake}}', []); - - switch ($action) { - case 'delete': - // make sure allowed to delete - $this->authorize('delete_{{pluralSnake}}'); - - {{singularStudly}}::whereIn('id', $ids) - ->get() - ->each(function ({{singularStudly}} ${{singularSnake}}) { - ${{singularSnake}}->delete(); - }); - break; - - case 'ban': - case 'approve': - // make sure allowed to approve - $this->authorize('approve', {{singularStudly}}::class); - - {{singularStudly}}::whereIn('id', $ids) - ->get() - ->each(function ({{singularStudly}} ${{singularSnake}}) use ($action) { - ${{singularSnake}}->{$action}(); - ${{singularSnake}}->save(); - }); - break; - } - - $this->flashSuccessMessage(); - - return $this->redirect($request, action([{{pluralStudly}}Controller::class, 'index'])); - } -} diff --git a/stubs/Controllers/Auth/ConfirmPasswordController.stub b/stubs/Controllers/Auth/ConfirmPasswordController.stub deleted file mode 100644 index a7a3a42..0000000 --- a/stubs/Controllers/Auth/ConfirmPasswordController.stub +++ /dev/null @@ -1,20 +0,0 @@ -middleware('guest:web_{{singularSnake}}'); - } - - #[\Override] - public function getBroker(): PasswordBroker - { - return Password::broker('{{pluralSnake}}'); - } - - #[\Override] - public function getPasswordResetForm(): View - { - return view('{{kebabAuthName}}.auth.passwords.email'); - } -} diff --git a/stubs/Controllers/Auth/HomeController.stub b/stubs/Controllers/Auth/HomeController.stub deleted file mode 100644 index 4665d8c..0000000 --- a/stubs/Controllers/Auth/HomeController.stub +++ /dev/null @@ -1,21 +0,0 @@ -user(); - - return view('{{kebabAuthName}}.home.show', compact('{{singularSnake}}')); - } -} diff --git a/stubs/Controllers/Auth/LoginController.stub b/stubs/Controllers/Auth/LoginController.stub deleted file mode 100644 index 348c59a..0000000 --- a/stubs/Controllers/Auth/LoginController.stub +++ /dev/null @@ -1,19 +0,0 @@ -userClass(); - - ${{singularSnake}} = new $class(); - - ${{singularSnake}}->name = $data['name']; - ${{singularSnake}}->email = $data['email']; - ${{singularSnake}}->password = $data['password']; - ${{singularSnake}}->status = UserStatuses::APPROVED; - // assignments - - ${{singularSnake}}->save(); - - return ${{singularSnake}}; - } -} diff --git a/stubs/Controllers/Auth/ResetPasswordController.stub b/stubs/Controllers/Auth/ResetPasswordController.stub deleted file mode 100644 index 6348a70..0000000 --- a/stubs/Controllers/Auth/ResetPasswordController.stub +++ /dev/null @@ -1,25 +0,0 @@ -user(); - - return view('{{kebabAuthName}}.account.edit', compact('{{singularSnake}}')); - } - - /** - * Update the {{singularLower}} account - */ - public function update({{pluralStudly}}Request $request) - { - /** @var {{singularStudly}} ${{singularSnake}} */ - ${{singularSnake}} = $request->user(); - - if ($action = $request->input('action')) { - switch ($action) { - case 'update_password': - // update password - if ($password = $request->input('password')) { - ${{singularSnake}}->password = $password; - ${{singularSnake}}->save(); - - flash_push('alerts', [ - 'text' => __('Password changed successfully'), - 'type' => 'success', - 'title' => __('Success!'), - ]); - } - break; - } - } else { - // update the email - if (($new_email = $request->email) && $new_email != ${{singularSnake}}->email) { - $new_email = ${{singularSnake}}->requestEmailUpdate($new_email); - if ($new_email) { - flash_push('alerts', [ - 'text' => __('We\'ve sent a verification link to :new_email. Please verify the new email address to update it', compact('new_email')), - 'type' => 'success', - 'title' => __('New Email Verification Sent'), - ]); - } - } - - // fill update - // booleans update - // foreign keys update - ${{singularSnake}}->save(); - - // update avatar - ${{singularSnake}}->updateSingleMedia('avatar', $request); - - $this->flashSuccessMessage(); - } - - return $this->redirect($request, route('{{kebabAuthName}}.account')); - } -} diff --git a/stubs/Controllers/Auth/VerificationController.stub b/stubs/Controllers/Auth/VerificationController.stub deleted file mode 100644 index ad3a697..0000000 --- a/stubs/Controllers/Auth/VerificationController.stub +++ /dev/null @@ -1,25 +0,0 @@ - ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('{{pluralSnake}}', 'email')], - 'password' => ['required', 'string', Password::min(8), 'confirmed'], - // rules - 'agreement' => ['accepted'], - ]; - - if (! app()->runningUnitTests()) { - $rules[recaptchaFieldName()] = recaptchaRuleName(); - } - - return Validator::make($data, $rules); diff --git a/stubs/Controllers/ModelApiController.stub b/stubs/Controllers/ModelApiController.stub deleted file mode 100644 index 1c483ea..0000000 --- a/stubs/Controllers/ModelApiController.stub +++ /dev/null @@ -1,89 +0,0 @@ -getHidden()); - } - - /** - * Get the allowed includes - * - * @return array - */ - public function getAllowedIncludes(): array - { - return [ - // allowed includes - ]; - } - - /** - * Get the allowed appends - * - * @return array - */ - public function getAllowedAppends(): array - { - return [ - ]; - } - - /** - * Get the allowed sorts - * - * @return array - */ - public function getAllowedSorts(): array - { - return [ - // allowed sorts - ]; - } - - /** - * Get the default sort - * - * @return string - */ - public function getDefaultSort(): string - { - return '{{defaultSort}}'; - } - - /** - * Get the allowed filters - * - * @return array - */ - public function getAllowedFilters(): array - { - return [ - // allowed filters - AllowedFilter::scope('search'), - ]; - } -} diff --git a/stubs/Controllers/ModelController.stub b/stubs/Controllers/ModelController.stub deleted file mode 100644 index 15ee537..0000000 --- a/stubs/Controllers/ModelController.stub +++ /dev/null @@ -1,168 +0,0 @@ -authorizeResource({{singularStudly}}::class); - } - - /** - * Initialize orderbys - */ - protected static function initOrderbys() - { - static::$orderbys = [ - ]; - } - - /** - * Display a listing of the resource. - */ - public function index(Request $request) - { - $title = __('All {{pluralTitle}}'); - $orderby = $this->getOrderBy($request, 'created_at'); - $order = $this->getOrder($request, 'created_at', $orderby); - $per_page = $this->getPerPage($request); - - ${{pluralSnake}} = {{singularStudly}}::orderBy($orderby, $order); - - $search = null; - if ($search = $request->input('search')) { - ${{pluralSnake}}->search($search); - $title = __('{{pluralTitle}} matching \':search\'', ['search' => $search]); - } - - if ($date_field = $request->input('date_field')) { - ${{pluralSnake}}->dateBetween($date_field, $request->input('date_from'), $request->input('date_to')); - } - // eager loads - if ($request->download) { - return (new {{pluralStudly}}Export(${{pluralSnake}}))->download('{{pluralKebab}}.xlsx'); - } - - ${{pluralSnake}} = ${{pluralSnake}}->paginate($per_page) - {{appendSpaces}}->appends($request->except('page')); - - return view('admin.{{pluralKebab}}.index', compact('{{pluralSnake}}', 'title', 'per_page', 'search')); - } - - /** - * Show the form for creating a new resource. - */ - public function create(Request $request) - { - return view('admin.{{pluralKebab}}.create'); - } - - /** - * Store a newly created resource in storage. - */ - public function store({{pluralStudly}}Request $request) - { - ${{singularSnake}} = new {{singularStudly}}({{requestFillable}}); - // booleans - // foreign keys - - ${{singularSnake}}->save(); - - $this->flashSuccessMessage(); - - return redirect()->action([{{pluralStudly}}Controller::class, 'edit'], ${{singularSnake}}); - } - - /** - * Display the specified resource. - */ - public function show({{singularStudly}} ${{singularSnake}}) - { - return view('admin.{{pluralKebab}}.show', compact('{{singularSnake}}')); - } - - /** - * Show the form for editing the specified resource. - */ - public function edit({{singularStudly}} ${{singularSnake}}) - { - return view('admin.{{pluralKebab}}.edit', compact('{{singularSnake}}')); - } - - /** - * Update the specified resource in storage. - */ - public function update({{pluralStudly}}Request $request, {{singularStudly}} ${{singularSnake}}) - { - // fill update - // booleans update - // foreign keys update - - ${{singularSnake}}->save(); - - $this->flashSuccessMessage(); - - return redirect()->action([{{pluralStudly}}Controller::class, 'edit'], ${{singularSnake}}); - } - - /** - * Remove the specified resource from storage. - */ - public function destroy({{singularStudly}} ${{singularSnake}}, Request $request) - { - ${{singularSnake}}->delete(); - - if ($request->expectsJson()) { - return response()->json(true); - } - - return redirect()->action([{{pluralStudly}}Controller::class, 'index']); - } - - /** - * Perform bulk action on the resource - */ - public function bulk(Request $request) - { - $this->authorize('viewAny', {{singularStudly}}::class); - - $this->validate($request, [ - 'action' => 'required|in:delete', - '{{pluralSnake}}' => 'required|array', - '{{pluralSnake}}.*' => 'exists:{{pluralSnake}},{{keyName}}', - ]); - - $action = $request->input('action'); - $ids = $request->input('{{pluralSnake}}', []); - - switch ($action) { - case 'delete': - // make sure allowed to delete - $this->authorize('delete_{{pluralSnake}}'); - - {{singularStudly}}::whereIn('id', $ids) - ->get() - ->each(function ({{singularStudly}} ${{singularSnake}}) { - ${{singularSnake}}->delete(); - }); - break; - } - - $this->flashSuccessMessage(); - - return $this->redirect($request, action([{{pluralStudly}}Controller::class, 'index'])); - } -} diff --git a/stubs/Controllers/ModelSoftDeletesController.stub b/stubs/Controllers/ModelSoftDeletesController.stub deleted file mode 100644 index a7c0e7d..0000000 --- a/stubs/Controllers/ModelSoftDeletesController.stub +++ /dev/null @@ -1,224 +0,0 @@ -authorizeResource({{singularStudly}}::class); - } - - /** - * Initialize orderbys - */ - protected static function initOrderbys() - { - static::$orderbys = [ - ]; - } - - /** - * Display a listing of the resource. - */ - public function index(Request $request, bool $trashed = false) - { - $title = $trashed ? __('All Deleted {{pluralTitle}}') : __('All {{pluralTitle}}'); - $orderby = $this->getOrderBy($request, 'created_at'); - $order = $this->getOrder($request, 'created_at', $orderby); - $per_page = $this->getPerPage($request); - - ${{pluralSnake}} = {{singularStudly}}::orderBy($orderby, $order); - - $search = null; - if ($search = $request->input('search')) { - ${{pluralSnake}}->search($search); - $title = __('{{pluralTitle}} matching \':search\'', ['search' => $search]); - } - - if ($date_field = $request->input('date_field')) { - ${{pluralSnake}}->dateBetween($date_field, $request->input('date_from'), $request->input('date_to')); - } - - if ($trashed) { - ${{pluralSnake}}->onlyTrashed(); - } - // eager loads - if ($request->download) { - return (new {{pluralStudly}}Export(${{pluralSnake}}))->download('{{pluralKebab}}.xlsx'); - } - - ${{pluralSnake}} = ${{pluralSnake}}->paginate($per_page) - {{appendSpaces}}->appends($request->except('page')); - - return view('admin.{{pluralKebab}}.index', compact('{{pluralSnake}}', 'title', 'per_page', 'search', 'trashed')); - } - - /** - * Show the form for creating a new resource. - */ - public function create(Request $request) - { - return view('admin.{{pluralKebab}}.create'); - } - - /** - * Store a newly created resource in storage. - */ - public function store({{pluralStudly}}Request $request) - { - ${{singularSnake}} = new {{singularStudly}}({{requestFillable}}); - // booleans - // foreign keys - - ${{singularSnake}}->save(); - - $this->flashSuccessMessage(); - - return redirect()->action([{{pluralStudly}}Controller::class, 'edit'], ${{singularSnake}}); - } - - /** - * Display the specified resource. - */ - public function show({{singularStudly}} ${{singularSnake}}) - { - return view('admin.{{pluralKebab}}.show', compact('{{singularSnake}}')); - } - - /** - * Show the form for editing the specified resource. - */ - public function edit({{singularStudly}} ${{singularSnake}}) - { - return view('admin.{{pluralKebab}}.edit', compact('{{singularSnake}}')); - } - - /** - * Update the specified resource in storage. - */ - public function update({{pluralStudly}}Request $request, {{singularStudly}} ${{singularSnake}}) - { - // fill update - // booleans update - // foreign keys update - - ${{singularSnake}}->save(); - - $this->flashSuccessMessage(); - - return redirect()->action([{{pluralStudly}}Controller::class, 'edit'], ${{singularSnake}}); - } - - /** - * Remove the specified resource from storage. - */ - public function destroy({{singularStudly}} ${{singularSnake}}, Request $request) - { - ${{singularSnake}}->delete(); - - if ($request->expectsJson()) { - return response()->json(true); - } - - return redirect()->action([{{pluralStudly}}Controller::class, 'index']); - } - - /** - * Display a listing of the deleted resources. - */ - public function trash(Request $request) - { - $this->authorize('viewTrash', {{singularStudly}}::class); - - return $this->index($request, true); - } - - /** - * Force delete the resource - */ - public function forceDelete({{singularStudly}} ${{singularSnake}}, Request $request) - { - // make sure it is trashed - if (! ${{singularSnake}}->trashed()) { - abort(404); - } - - $this->authorize('forceDelete', ${{singularSnake}}); - - ${{singularSnake}}->forceDelete(); - - if ($request->expectsJson()) { - return response()->json(true); - } - - return redirect()->action([{{pluralStudly}}Controller::class, 'trash']); - } - - /** - * Restore deleted resource - */ - public function restore({{singularStudly}} ${{singularSnake}}, Request $request) - { - // make sure it is trashed - if (! ${{singularSnake}}->trashed()) { - abort(404); - } - - $this->authorize('restore', ${{singularSnake}}); - - ${{singularSnake}}->restore(); - - if ($request->expectsJson()) { - return response()->json(true); - } - - return redirect()->action([{{pluralStudly}}Controller::class, 'trash']); - } - - /** - * Perform bulk action on the resource - */ - public function bulk(Request $request) - { - $this->authorize('viewAny', {{singularStudly}}::class); - - $this->validate($request, [ - 'action' => 'required|in:delete', - '{{pluralSnake}}' => 'required|array', - '{{pluralSnake}}.*' => 'exists:{{pluralSnake}},{{keyName}}', - ]); - - $action = $request->input('action'); - $ids = $request->input('{{pluralSnake}}', []); - - switch ($action) { - case 'delete': - // make sure allowed to delete - $this->authorize('delete_{{pluralSnake}}'); - - {{singularStudly}}::whereIn('id', $ids) - ->get() - ->each(function ({{singularStudly}} ${{singularSnake}}) { - ${{singularSnake}}->delete(); - }); - break; - } - - $this->flashSuccessMessage(); - - return $this->redirect($request, action([{{pluralStudly}}Controller::class, 'index'])); - } -} diff --git a/stubs/Controllers/_controllerBoolean.stub b/stubs/Controllers/_controllerBoolean.stub deleted file mode 100644 index c9a98b7..0000000 --- a/stubs/Controllers/_controllerBoolean.stub +++ /dev/null @@ -1,3 +0,0 @@ - if ($request->hasAny(['{{inputName}}', 'sync_{{inputName}}'])) { - ${{singularSnake}}->{{column}} = $request->input('{{inputName}}', false); - } diff --git a/stubs/Controllers/_controllerEagerLoads.stub b/stubs/Controllers/_controllerEagerLoads.stub deleted file mode 100644 index a26ac45..0000000 --- a/stubs/Controllers/_controllerEagerLoads.stub +++ /dev/null @@ -1,2 +0,0 @@ - - ${{pluralSnake}}->with(); diff --git a/stubs/Controllers/_controllerForeignKey.stub b/stubs/Controllers/_controllerForeignKey.stub deleted file mode 100644 index 845ca7c..0000000 --- a/stubs/Controllers/_controllerForeignKey.stub +++ /dev/null @@ -1,3 +0,0 @@ - if ($request->has('{{attribute}}')) { - ${{singularSnakeModel}}->{{relationName}}()->associate($request->input('{{attribute}}')); - } diff --git a/stubs/Controllers/_controllerUpdateFill.stub b/stubs/Controllers/_controllerUpdateFill.stub deleted file mode 100644 index 8f7f15f..0000000 --- a/stubs/Controllers/_controllerUpdateFill.stub +++ /dev/null @@ -1 +0,0 @@ - ${{singularSnake}}->fill($request->validated()); diff --git a/stubs/Controllers/_orderBy.stub b/stubs/Controllers/_orderBy.stub deleted file mode 100644 index e732c8c..0000000 --- a/stubs/Controllers/_orderBy.stub +++ /dev/null @@ -1 +0,0 @@ - '{{name}}' => __('{{title}}'), diff --git a/stubs/Exports/ModelExport.stub b/stubs/Exports/ModelExport.stub deleted file mode 100644 index a6c7236..0000000 --- a/stubs/Exports/ModelExport.stub +++ /dev/null @@ -1,18 +0,0 @@ -controller(\App\Http\Controllers\Admin\{{pluralStudly}}Controller::class) - ->can('viewAny', \App\Models\{{singularStudly}}::class) - ->icon('{{icon}}'), diff --git a/stubs/Models/AuthModel.stub b/stubs/Models/AuthModel.stub deleted file mode 100644 index 0eb1f05..0000000 --- a/stubs/Models/AuthModel.stub +++ /dev/null @@ -1,105 +0,0 @@ -user() instanceof {{singularStudly}} ? - auth()->user() : - auth()->guard('web_{{singularSnake}}')->user(); - - if (${{singularSnake}}) { - return $query->where($this->getTable().'.id', ${{singularSnake}}->id); - } - - // then try admin - $admin = auth()->user() instanceof User ? - auth()->user() : - auth()->guard('web_admin')->user(); - - if ($admin) { - if ($admin->can('viewAny', static::class)) { - // can view all - return $query; - } - } - - // can't view any - return $query->where($this->getTable().'.id', -1); - } - // admin link name - // foreign keys -} diff --git a/stubs/Models/Model.stub b/stubs/Models/Model.stub deleted file mode 100644 index 3ac534d..0000000 --- a/stubs/Models/Model.stub +++ /dev/null @@ -1,58 +0,0 @@ -{{attribute}}; - } diff --git a/stubs/Models/_authCasts.stub b/stubs/Models/_authCasts.stub deleted file mode 100644 index 7f6c0ee..0000000 --- a/stubs/Models/_authCasts.stub +++ /dev/null @@ -1,13 +0,0 @@ - /** - * Get the attributes that should be cast. - * - * @return array - */ - protected function casts(): array - { - $casts = parent::casts(); - - return $casts + [ -// casts - ]; - } diff --git a/stubs/Models/_casts.stub b/stubs/Models/_casts.stub deleted file mode 100644 index ceb85fb..0000000 --- a/stubs/Models/_casts.stub +++ /dev/null @@ -1,11 +0,0 @@ - /** - * Get the attributes that should be cast. - * - * @return array - */ - protected function casts(): array - { - return [ -// casts - ]; - } diff --git a/stubs/Models/_dateMutator.stub b/stubs/Models/_dateMutator.stub deleted file mode 100644 index af0b649..0000000 --- a/stubs/Models/_dateMutator.stub +++ /dev/null @@ -1,7 +0,0 @@ - /** - * Convert dates to Carbon - */ - public function set{{attributeStudly}}Attribute($value) - { - return $this->attributes['{{attribute}}'] = $value ? Carbon::parse($value) : now(); - } diff --git a/stubs/Models/_modelForeignKey.stub b/stubs/Models/_modelForeignKey.stub deleted file mode 100644 index bbc600d..0000000 --- a/stubs/Models/_modelForeignKey.stub +++ /dev/null @@ -1,7 +0,0 @@ - /** - * A {{singularLowerModel}} belongs to a {{singularLower}} - */ - public function {{relationName}}(): BelongsTo - { - return $this->{{relationStatement}}; - } diff --git a/stubs/Models/_nullableDateMutator.stub b/stubs/Models/_nullableDateMutator.stub deleted file mode 100644 index 27950a4..0000000 --- a/stubs/Models/_nullableDateMutator.stub +++ /dev/null @@ -1,7 +0,0 @@ - /** - * Convert dates to Carbon - */ - public function set{{attributeStudly}}Attribute($value) - { - return $this->attributes['{{attribute}}'] = $value ? Carbon::parse($value) : null; - } diff --git a/stubs/Policies/ModelPolicy.stub b/stubs/Policies/ModelPolicy.stub deleted file mode 100644 index da579d5..0000000 --- a/stubs/Policies/ModelPolicy.stub +++ /dev/null @@ -1,60 +0,0 @@ -can('view_{{pluralSnake}}'); - } - - /** - * Determine whether the user can view the model. - */ - public function view(User $user, {{singularStudly}} ${{singularSnake}}): bool - { - return $user->can('view_{{pluralSnake}}'); - } - - /** - * Determine whether the user can create models. - */ - public function create(User $user): bool - { - return $user->can('edit_{{pluralSnake}}'); - } - - /** - * Determine whether the user can update the model. - */ - public function update(User $user, {{singularStudly}} ${{singularSnake}}): bool - { - return $user->can('edit_{{pluralSnake}}'); - } - - /** - * Determine whether the user can delete the model. - */ - public function delete(User $user, {{singularStudly}} ${{singularSnake}}): bool - { - return $user->can('delete_{{pluralSnake}}'); - } - - /** - * Determine whether the user can view the model logs. - */ - public function viewLogs(User $user, {{singularStudly}} ${{singularSnake}}): bool - { - return $user->can('viewAny', Activity::class) && $this->update($user, ${{singularSnake}}); - } - // additional policy methods -} diff --git a/stubs/Policies/ModelSoftDeletesPolicy.stub b/stubs/Policies/ModelSoftDeletesPolicy.stub deleted file mode 100644 index dc5e274..0000000 --- a/stubs/Policies/ModelSoftDeletesPolicy.stub +++ /dev/null @@ -1,84 +0,0 @@ -can('view_{{pluralSnake}}'); - } - - /** - * Determine whether the user can view the model. - */ - public function view(User $user, {{singularStudly}} ${{singularSnake}}): bool - { - return $user->can('view_{{pluralSnake}}'); - } - - /** - * Determine whether the user can create models. - */ - public function create(User $user): bool - { - return $user->can('edit_{{pluralSnake}}'); - } - - /** - * Determine whether the user can update the model. - */ - public function update(User $user, {{singularStudly}} ${{singularSnake}}): bool - { - return $user->can('edit_{{pluralSnake}}'); - } - - /** - * Determine whether the user can delete the model. - */ - public function delete(User $user, {{singularStudly}} ${{singularSnake}}): bool - { - return $user->can('delete_{{pluralSnake}}'); - } - - /** - * Determine whether the user can view trashed models. - */ - public function viewTrash(User $user): bool - { - return $user->can('delete_{{pluralSnake}}') || $user->can('force_delete_{{pluralSnake}}'); - } - - /** - * Determine whether the user can restore the model. - */ - public function restore(User $user, {{singularStudly}} ${{singularSnake}}): bool - { - return $user->can('delete_{{pluralSnake}}') || $user->can('force_delete_{{pluralSnake}}'); - } - - /** - * Determine whether the user can permanently delete the model. - */ - public function forceDelete(User $user, {{singularStudly}} ${{singularSnake}}): bool - { - return $user->can('force_delete_{{pluralSnake}}'); - } - - /** - * Determine whether the user can view the model logs. - */ - public function viewLogs(User $user, {{singularStudly}} ${{singularSnake}}): bool - { - return $user->can('viewAny', Activity::class) && $this->update($user, ${{singularSnake}}); - } - // additional policy methods -} diff --git a/stubs/Policies/_authPolicyMethods.stub b/stubs/Policies/_authPolicyMethods.stub deleted file mode 100644 index 122f78c..0000000 --- a/stubs/Policies/_authPolicyMethods.stub +++ /dev/null @@ -1,16 +0,0 @@ - - /** - * Determine whether the user can approve {{pluralLower}}. - */ - public function approve(User $user): bool - { - return $user->can('approve_{{pluralSnake}}') && $this->create($user); - } - - /** - * Determine whether the user can resend the verification for the {{singularLower}}. - */ - public function resendVerification(User $user, {{singularStudly}} ${{singularSnake}}): bool - { - return ${{singularSnake}}->needsEmailVerification() && $this->update($user, ${{singularSnake}}); - } diff --git a/stubs/Requests/AuthModelRequest.stub b/stubs/Requests/AuthModelRequest.stub deleted file mode 100644 index 6a94002..0000000 --- a/stubs/Requests/AuthModelRequest.stub +++ /dev/null @@ -1,56 +0,0 @@ -user() instanceof {{singularStudly}}) { - if (${{singularSnake}} = $this->getRouteUser()) { - return ${{singularSnake}}->id == $this->user()->id; - } else { - return if_route_pattern(['{{kebabAuthName}}.*', 'api.{{singularKebab}}.update']); - } - } - - return false; - } - - /** - * Get the route user - */ - protected function getRouteUser(): ?\Javaabu\Auth\User - { - return $this->route('{{singularSnake}}'); - } - - /** - * Get the validation rules that apply to the request. - * - * @return array|string> - */ - public function rules(): array - { - $rules = {{baseRule}}; - - // rules body - } -} diff --git a/stubs/Requests/ModelRequest.stub b/stubs/Requests/ModelRequest.stub deleted file mode 100644 index b43a359..0000000 --- a/stubs/Requests/ModelRequest.stub +++ /dev/null @@ -1,27 +0,0 @@ -|string> - */ - public function rules(): array - { - // rules body - } -} diff --git a/stubs/Requests/_authBlankRulesBody.stub b/stubs/Requests/_authBlankRulesBody.stub deleted file mode 100644 index 0fbdf2d..0000000 --- a/stubs/Requests/_authBlankRulesBody.stub +++ /dev/null @@ -1 +0,0 @@ - return $rules; diff --git a/stubs/Requests/_authRulesBody.stub b/stubs/Requests/_authRulesBody.stub deleted file mode 100644 index 5a266c2..0000000 --- a/stubs/Requests/_authRulesBody.stub +++ /dev/null @@ -1,13 +0,0 @@ - $rules += [ - // rules - ]; - - // unique definitions - if (${{singularSnake}} = $this->getUserBeingEdited()) { - // unique ignores - } else { - // requireds - } - - // unique rule insertions - return $rules; diff --git a/stubs/Requests/_rulesBody.stub b/stubs/Requests/_rulesBody.stub deleted file mode 100644 index 78fdb34..0000000 --- a/stubs/Requests/_rulesBody.stub +++ /dev/null @@ -1,13 +0,0 @@ - $rules = [ - // rules - ]; - - // unique definitions - if (${{singularSnake}} = $this->route('{{singularSnake}}')) { - // unique ignores - } else { - // requireds - } - - // unique rule insertions - return $rules; diff --git a/stubs/auth/migration.stub b/stubs/auth/migration.stub new file mode 100644 index 0000000..cb07646 --- /dev/null +++ b/stubs/auth/migration.stub @@ -0,0 +1,24 @@ + env('AUTH_GUARD', 'web_{{singularSnake}}'), diff --git a/stubs/config/auth/_default-passwords.stub b/stubs/config/auth/_default-passwords.stub deleted file mode 100644 index ae9f132..0000000 --- a/stubs/config/auth/_default-passwords.stub +++ /dev/null @@ -1 +0,0 @@ -'passwords' => env('AUTH_PASSWORD_BROKER', '{{pluralSnake}}'), diff --git a/stubs/config/auth/_guards.stub b/stubs/config/auth/_guards.stub deleted file mode 100644 index 754cf2c..0000000 --- a/stubs/config/auth/_guards.stub +++ /dev/null @@ -1,9 +0,0 @@ - 'web_{{singularSnake}}' => [ - 'driver' => 'session', - 'provider' => '{{pluralSnake}}', - ], - - 'api_{{singularSnake}}' => [ - 'driver' => 'passport', - 'provider' => '{{pluralSnake}}', - ], diff --git a/stubs/config/auth/_passport-guards.stub b/stubs/config/auth/_passport-guards.stub deleted file mode 100644 index c8ad6d0..0000000 --- a/stubs/config/auth/_passport-guards.stub +++ /dev/null @@ -1 +0,0 @@ - 'api_{{singularSnake}}', diff --git a/stubs/config/auth/_passwords.stub b/stubs/config/auth/_passwords.stub deleted file mode 100644 index eff62fb..0000000 --- a/stubs/config/auth/_passwords.stub +++ /dev/null @@ -1,6 +0,0 @@ - '{{pluralSnake}}' => [ - 'provider' => '{{pluralSnake}}', - 'table' => '{{singularSnake}}_password_reset_tokens', - 'expire' => 60, - 'throttle' => 60, - ], diff --git a/stubs/config/auth/_providers.stub b/stubs/config/auth/_providers.stub deleted file mode 100644 index c2f5bb1..0000000 --- a/stubs/config/auth/_providers.stub +++ /dev/null @@ -1,4 +0,0 @@ - '{{pluralSnake}}' => [ - 'driver' => 'eloquent', - 'model' => App\Models\{{singularStudly}}::class, - ], diff --git a/stubs/controller/Controller.stub b/stubs/controller/Controller.stub new file mode 100644 index 0000000..d2116ae --- /dev/null +++ b/stubs/controller/Controller.stub @@ -0,0 +1,10 @@ + - */ -class {{singularStudly}}Factory extends \Javaabu\Auth\Factories\UserFactory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - $definitions = parent::definition(); - - $definitions['password'] = '{{password}}'; - - $definitions += [ - // definition - ]; - - return $definitions; - } - - // foreign keys -} diff --git a/stubs/factories/ModelFactory.stub b/stubs/factories/ModelFactory.stub deleted file mode 100644 index 6417e6c..0000000 --- a/stubs/factories/ModelFactory.stub +++ /dev/null @@ -1,25 +0,0 @@ - - */ -class {{singularStudly}}Factory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - // definition - ]; - } - - // foreign keys -} diff --git a/stubs/factories/_factoryForeignKey.stub b/stubs/factories/_factoryForeignKey.stub deleted file mode 100644 index 1d9e828..0000000 --- a/stubs/factories/_factoryForeignKey.stub +++ /dev/null @@ -1,8 +0,0 @@ - public function with{{relationName}}(): {{factoryName}} - { - return $this->state(function (array $attributes) { - return [ - // statement - ]; - }); - } diff --git a/stubs/factories/_factoryRequiredRelations.stub b/stubs/factories/_factoryRequiredRelations.stub deleted file mode 100644 index ef09ff8..0000000 --- a/stubs/factories/_factoryRequiredRelations.stub +++ /dev/null @@ -1,4 +0,0 @@ - public function withRequiredRelations(): {{factoryName}} - { - return $this; - } diff --git a/stubs/factory/Factory.stub b/stubs/factory/Factory.stub new file mode 100644 index 0000000..d2faa35 --- /dev/null +++ b/stubs/factory/Factory.stub @@ -0,0 +1,30 @@ + + */ +class {{ class }} extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string<{{ model }}> + */ + protected $model = {{ model }}::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + {{ definition }} + ]; + } +} diff --git a/stubs/filters/Filters.stub b/stubs/filters/Filters.stub new file mode 100644 index 0000000..0191974 --- /dev/null +++ b/stubs/filters/Filters.stub @@ -0,0 +1,28 @@ +string('email')->index(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('{{singularSnake}}_password_reset_tokens'); - } -}; diff --git a/stubs/model/Model.stub b/stubs/model/Model.stub new file mode 100644 index 0000000..b21a088 --- /dev/null +++ b/stubs/model/Model.stub @@ -0,0 +1,21 @@ + + */ + protected $fillable = [ + {{ fillable }} + ]; + + {{ body }} +} diff --git a/stubs/policy/Policy.stub b/stubs/policy/Policy.stub new file mode 100644 index 0000000..2c22d18 --- /dev/null +++ b/stubs/policy/Policy.stub @@ -0,0 +1,17 @@ + + */ + public function toArray(Request $request): array + { + return [ + {{ attributes }} + ]; + } +} diff --git a/stubs/routes/_admin.stub b/stubs/routes/_admin.stub deleted file mode 100644 index c90aaca..0000000 --- a/stubs/routes/_admin.stub +++ /dev/null @@ -1,5 +0,0 @@ - /** - * {{pluralTitle}} - */ - Route::match(['PUT', 'PATCH'], '{{pluralKebab}}', [{{pluralStudly}}Controller::class, 'bulk'])->name('{{pluralKebab}}.bulk'); - Route::resource('{{pluralKebab}}', {{pluralStudly}}Controller::class); diff --git a/stubs/routes/_adminSoftDeletes.stub b/stubs/routes/_adminSoftDeletes.stub deleted file mode 100644 index e3f4fc3..0000000 --- a/stubs/routes/_adminSoftDeletes.stub +++ /dev/null @@ -1,8 +0,0 @@ - /** - * {{pluralTitle}} - */ - Route::match(['PUT', 'PATCH'], '{{pluralKebab}}', [{{pluralStudly}}Controller::class, 'bulk'])->name('{{pluralKebab}}.bulk'); - Route::get('{{pluralKebab}}/trash', [{{pluralStudly}}Controller::class, 'trash'])->name('{{pluralKebab}}.trash'); - Route::post('{{pluralKebab}}/{{{singularSnake}}}/restore', [{{pluralStudly}}Controller::class, 'restore'])->name('{{pluralKebab}}.restore')->withTrashed(); - Route::delete('{{pluralKebab}}/{{{singularSnake}}}/force-delete', [{{pluralStudly}}Controller::class, 'forceDelete'])->name('{{pluralKebab}}.force-delete')->withTrashed(); - Route::resource('{{pluralKebab}}', {{pluralStudly}}Controller::class); diff --git a/stubs/routes/_api.stub b/stubs/routes/_api.stub deleted file mode 100644 index f7b223c..0000000 --- a/stubs/routes/_api.stub +++ /dev/null @@ -1,5 +0,0 @@ - /** - * {{pluralTitle}} - */ - Route::get('{{pluralKebab}}', [{{pluralStudly}}Controller::class, 'index'])->name('{{pluralKebab}}.index'); - Route::get('{{pluralKebab}}/{id}', [{{pluralStudly}}Controller::class, 'show'])->name('{{pluralKebab}}.show'); diff --git a/stubs/routes/admin.stub b/stubs/routes/admin.stub new file mode 100644 index 0000000..5a45aff --- /dev/null +++ b/stubs/routes/admin.stub @@ -0,0 +1,20 @@ +prefix('{{ prefix }}') + ->name('{{ name }}') + ->group(function () { + // generators: routes + }); diff --git a/stubs/seeders/PermissionsSeeder.stub b/stubs/seeders/PermissionsSeeder.stub deleted file mode 100644 index 164dd3a..0000000 --- a/stubs/seeders/PermissionsSeeder.stub +++ /dev/null @@ -1,26 +0,0 @@ -data as $model => $permissions) { - foreach ($permissions as $name => $desc) { - $permission = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web_admin']); - $permission->update(['description' => $desc, 'model' => $model]); - $permission->save(); - } - } - } -} diff --git a/stubs/seeders/_authPermissions.stub b/stubs/seeders/_authPermissions.stub deleted file mode 100644 index a14d25d..0000000 --- a/stubs/seeders/_authPermissions.stub +++ /dev/null @@ -1 +0,0 @@ - 'approve_{{pluralSnake}}' => 'Approve {{pluralLower}}', diff --git a/stubs/seeders/_permissions.stub b/stubs/seeders/_permissions.stub deleted file mode 100644 index 457967d..0000000 --- a/stubs/seeders/_permissions.stub +++ /dev/null @@ -1,9 +0,0 @@ - /** - * {{pluralTitle}} permissions - */ - '{{pluralSnake}}' => [ - 'view_{{pluralSnake}}' => 'View {{pluralLower}}', - 'edit_{{pluralSnake}}' => 'Edit {{pluralLower}}', - 'delete_{{pluralSnake}}' => 'Delete {{pluralLower}}', -// additional permissions - ], diff --git a/stubs/seeders/_permissionsSoftDeletes.stub b/stubs/seeders/_permissionsSoftDeletes.stub deleted file mode 100644 index e516d1a..0000000 --- a/stubs/seeders/_permissionsSoftDeletes.stub +++ /dev/null @@ -1,10 +0,0 @@ - /** - * {{pluralTitle}} permissions - */ - '{{pluralSnake}}' => [ - 'view_{{pluralSnake}}' => 'View {{pluralLower}}', - 'edit_{{pluralSnake}}' => 'Edit {{pluralLower}}', - 'delete_{{pluralSnake}}' => 'Delete {{pluralLower}}', - 'force_delete_{{pluralSnake}}' => 'Permanently delete {{pluralLower}}', -// additional permissions - ], diff --git a/stubs/test/ControllerTest.stub b/stubs/test/ControllerTest.stub new file mode 100644 index 0000000..b6cff18 --- /dev/null +++ b/stubs/test/ControllerTest.stub @@ -0,0 +1,37 @@ +artisan('permissions:sync'); + {{ setUp }} + } + + {{ tests }} + + /** + * Log in as a user of the admin area with these permissions. + */ + protected function actingAsAdmin(string ...$permissions): {{ user }} + { + $user = {{ user }}::factory()->create(); + + if ($permissions) { + $user->givePermissionTo($permissions); + } + + $this->actingAs($user{{ guard }}); + + return $user; + } +} diff --git a/stubs/tests/ModelApiControllerTest.stub b/stubs/tests/ModelApiControllerTest.stub deleted file mode 100644 index 3275fb4..0000000 --- a/stubs/tests/ModelApiControllerTest.stub +++ /dev/null @@ -1,78 +0,0 @@ -withoutExceptionHandling(); - - $access_cookie = $this->getOAuthCookie(null); - - ${{pluralSnake}} = $this->getFactory({{singularStudly}}::class)->count(10)->create(); - - $this->jsonApi('get', '/api/v1/{{pluralKebab}}', access_cookie: $access_cookie) - ->assertSuccessful() - ->assertJsonFragment([ - '{{nameField}}' => ${{pluralSnake}}->first()->{{nameField}}, - ]); - - } - - #[Test] - public function it_can_show_api_{{pluralSnake}}_for_public_users(): void - { - $this->withoutExceptionHandling(); - - $access_cookie = $this->getOAuthCookie(null); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->jsonApi('get', "/api/v1/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}", access_cookie: $access_cookie) - ->assertSuccessful() - ->assertJsonFragment([ - '{{nameField}}' => ${{singularSnake}}->{{nameField}}, - ]); - } - - #[Test] - public function it_can_list_api_{{pluralSnake}}_for_admin_users(): void - { - $this->withoutExceptionHandling(); - - $this->actingAsApiAdmin('view_{{pluralSnake}}'); - - ${{pluralSnake}} = $this->getFactory({{singularStudly}}::class)->count(10)->create(); - - $this->getJson('/api/v1/{{pluralKebab}}') - ->assertSuccessful() - ->assertJsonFragment([ - '{{nameField}}' => ${{pluralSnake}}->first()->{{nameField}}, - ]); - - } - - #[Test] - public function it_can_show_api_{{pluralSnake}}_for_admin_users(): void - { - $this->withoutExceptionHandling(); - - $this->actingAsApiAdmin('view_{{pluralSnake}}'); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->getJson("/api/v1/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}") - ->assertSuccessful() - ->assertJsonFragment([ - '{{nameField}}' => ${{singularSnake}}->{{nameField}}, - ]); - } -} diff --git a/stubs/tests/ModelControllerTest.stub b/stubs/tests/ModelControllerTest.stub deleted file mode 100644 index 47e58f8..0000000 --- a/stubs/tests/ModelControllerTest.stub +++ /dev/null @@ -1,358 +0,0 @@ -withoutExceptionHandling(); - - $this->actingAsAdmin('view_{{pluralSnake}}'); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->adminGet('/{{pluralKebab}}') - ->assertStatus(200) - ->assertSee(${{singularSnake}}->{{nameField}}); - } - - #[Test] - public function it_can_filter_{{pluralSnake}}() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_{{pluralSnake}}'); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->adminGet(add_query_arg([ - 'search' => ${{singularSnake}}->{{nameField}}, - 'date_field' => 'created_at', - 'date_from' => now()->subDay()->toDateTimeString(), - 'date_to' => now()->addDay()->toDateTimeString(), - ], '/{{pluralKebab}}')) - ->assertStatus(200) - ->assertSee(${{singularSnake}}->{{nameField}}); - } - - #[Test] - public function it_can_export_{{pluralSnake}}() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_{{pluralSnake}}'); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - Excel::fake(); - - $this->adminGet(add_query_arg(['download' => 1], '/{{pluralKebab}}')) - ->assertSuccessful(); - - Excel::assertDownloaded('{{pluralKebab}}.xlsx', function ({{pluralStudly}}Export $export) { - return $export->modelClass() == {{singularStudly}}::class; - }); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_list_{{pluralSnake}}() - { - $this->actingAsAdmin(); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->adminGet('/{{pluralKebab}}') - ->assertStatus(403) - ->assertDontSee(${{singularSnake}}->{{nameField}}); - } - - #[Test] - public function it_can_show_the_create_new_{{singularSnake}}_page() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_{{pluralSnake}}'); - - $this->adminGet('/{{pluralKebab}}/create') - ->assertStatus(200) - ->assertSee('New {{singularTitle}}'); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_see_the_create_new_{{singularSnake}}_page() - { - $this->actingAsAdmin(); - - $this->adminGet('/{{pluralKebab}}/create') - ->assertStatus(403) - ->assertDontSee('New {{singularTitle}}'); - } - - #[Test] - public function it_can_create_a_new_{{singularSnake}}() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_{{pluralSnake}}'); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->make(); - - $this->adminPost('/{{pluralKebab}}', [ - // factory inputs - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $this->assertDatabaseHas('{{tableName}}', [ - // factory db values - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_add_a_{{singularSnake}}() - { - $this->actingAsAdmin(); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->make(); - - $this->adminPost('/{{pluralKebab}}', [ - // factory inputs - ]) - ->assertSessionMissing('errors') - ->assertStatus(403); - - $this->assertDatabaseMissing('{{tableName}}', [ - // factory db values - ]); - } - - #[Test] - public function it_can_show_the_edit_{{singularSnake}}_page() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_{{pluralSnake}}'); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->adminGet("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}/edit") - ->assertStatus(200) - ->assertSee(${{singularSnake}}->{{nameField}}); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_see_the_edit_{{singularSnake}}_page() - { - $this->actingAsAdmin(); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->adminGet("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}/edit") - ->assertStatus(403) - ->assertDontSee(${{singularSnake}}->{{nameField}}); - } - - #[Test] - public function it_can_update_a_{{singularSnake}}() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_{{pluralSnake}}'); - - // old foreign key models - ${{singularSnake}} = {{singularStudly}}::factory()->create([ - // correct factory values - ]); - - // new foreign key models - $this->adminPatch("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}", [ - // different correct inputs - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $this->assertDatabaseHas('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - // different correct db values - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_update_a_{{singularSnake}}() - { - $this->actingAsAdmin(); - - // old foreign key models - ${{singularSnake}} = {{singularStudly}}::factory()->create([ - // correct factory values - ]); - - // new foreign key models - $this->adminPatch("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}", [ - // different correct inputs - ]) - ->assertSessionMissing('errors') - ->assertStatus(403); - - $this->assertDatabaseHas('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - // correct db values - ]); - } - - #[Test] - public function it_can_show_a_{{singularSnake}}() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_{{pluralSnake}}'); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->adminGet("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}") - ->assertStatus(200) - ->assertSee(${{singularSnake}}->{{nameField}}); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_view_a_{{singularSnake}}() - { - $this->actingAsAdmin(); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->adminGet("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}") - ->assertStatus(403) - ->assertDontSee(${{singularSnake}}->{{nameField}}); - } - - #[Test] - public function it_can_delete_a_{{singularSnake}}() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('delete_{{pluralSnake}}'); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->assertDatabaseHas('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - - $this->adminDelete("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}") - ->assertRedirect(); - - $this->assertDatabaseMissing('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_delete_a_{{singularSnake}}() - { - $this->actingAsAdmin(); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->assertDatabaseHas('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - - $this->adminDelete("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}") - ->assertStatus(403); - - $this->assertDatabaseHas('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - } - - #[Test] - public function it_can_bulk_delete_{{pluralSnake}}() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin(['view_{{pluralSnake}}', 'delete_{{pluralSnake}}']); - - ${{pluralSnake}} = $this->getFactory({{singularStudly}}::class)->count(3)->create(); - - ${{pluralSnake}} = ${{pluralSnake}}->map(function (${{singularSnake}}) { - return collect(${{singularSnake}}->toArray()) - ->only('{{keyName}}') - ->all(); - }); - - ${{pluralSnake}}->each(function (${{singularSnake}}) { - $this->assertDatabaseHas('{{tableName}}', ${{singularSnake}}); - }); - - ${{pluralSnake}}_to_delete = ${{pluralSnake}}->slice(1); - - $this->adminPatch('/{{pluralKebab}}', [ - '{{pluralSnake}}' => ${{pluralSnake}}_to_delete->pluck('{{keyName}}')->all(), - 'action' => 'delete', - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - ${{pluralSnake}}_to_delete->each(function (${{singularSnake}}) { - $this->assertDatabaseMissing('{{tableName}}', ${{singularSnake}}); - }); - } - - #[Test] - public function it_does_not_allow_unauthorized_users_to_bulk_delete_{{pluralSnake}}() - { - $this->actingAsAdmin('view_{{pluralSnake}}'); - - ${{pluralSnake}} = $this->getFactory({{singularStudly}}::class)->count(3)->create(); - - ${{pluralSnake}} = ${{pluralSnake}}->map(function (${{singularSnake}}) { - return collect(${{singularSnake}}->toArray()) - ->only('{{keyName}}') - ->all(); - }); - - ${{pluralSnake}}->each(function (${{singularSnake}}) { - $this->assertDatabaseHas('{{tableName}}', ${{singularSnake}}); - }); - - ${{pluralSnake}}_to_delete = ${{pluralSnake}}->slice(1); - - $this->adminPatch('/{{pluralKebab}}', [ - '{{pluralSnake}}' => ${{pluralSnake}}_to_delete->pluck('{{keyName}}')->all(), - 'action' => 'delete', - ]) - ->assertStatus(403) - ->assertSessionMissing('errors'); - - ${{pluralSnake}}_to_delete->each(function (${{singularSnake}}) { - $this->assertDatabaseHas('{{tableName}}', ${{singularSnake}}); - }); - } - - #[Test] - public function it_can_validate_{{singularSnake}}_inputs() - { - $this->actingAsAdmin('edit_{{pluralSnake}}'); - - $this->adminPost('/{{pluralKebab}}', [ - // wrong inputs - ]) - ->assertRedirect() - ->assertSessionHasErrors([ - // errors - ]); - - $this->assertDatabaseMissing('{{tableName}}', [ - // wrong db values - ]); - } -} diff --git a/stubs/tests/ModelSoftDeletesControllerTest.stub b/stubs/tests/ModelSoftDeletesControllerTest.stub deleted file mode 100644 index 124d643..0000000 --- a/stubs/tests/ModelSoftDeletesControllerTest.stub +++ /dev/null @@ -1,485 +0,0 @@ -withoutExceptionHandling(); - - $this->actingAsAdmin('view_{{pluralSnake}}'); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->adminGet('/{{pluralKebab}}') - ->assertStatus(200) - ->assertSee(${{singularSnake}}->{{nameField}}); - } - - #[Test] - public function it_can_filter_{{pluralSnake}}() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_{{pluralSnake}}'); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->adminGet(add_query_arg([ - 'search' => ${{singularSnake}}->{{nameField}}, - 'date_field' => 'created_at', - 'date_from' => now()->subDay()->toDateTimeString(), - 'date_to' => now()->addDay()->toDateTimeString(), - ], '/{{pluralKebab}}')) - ->assertStatus(200) - ->assertSee(${{singularSnake}}->{{nameField}}); - } - - #[Test] - public function it_can_export_{{pluralSnake}}() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_{{pluralSnake}}'); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - Excel::fake(); - - $this->adminGet(add_query_arg(['download' => 1], '/{{pluralKebab}}')) - ->assertSuccessful(); - - Excel::assertDownloaded('{{pluralKebab}}.xlsx', function ({{pluralStudly}}Export $export) { - return $export->modelClass() == {{singularStudly}}::class; - }); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_list_{{pluralSnake}}() - { - $this->actingAsAdmin(); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->adminGet('/{{pluralKebab}}') - ->assertStatus(403) - ->assertDontSee(${{singularSnake}}->{{nameField}}); - } - - #[Test] - public function it_can_show_the_create_new_{{singularSnake}}_page() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_{{pluralSnake}}'); - - $this->adminGet('/{{pluralKebab}}/create') - ->assertStatus(200) - ->assertSee('New {{singularTitle}}'); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_see_the_create_new_{{singularSnake}}_page() - { - $this->actingAsAdmin(); - - $this->adminGet('/{{pluralKebab}}/create') - ->assertStatus(403) - ->assertDontSee('New {{singularTitle}}'); - } - - #[Test] - public function it_can_create_a_new_{{singularSnake}}() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_{{pluralSnake}}'); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->make(); - - $this->adminPost('/{{pluralKebab}}', [ - // factory inputs - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $this->assertDatabaseHas('{{tableName}}', [ - // factory db values - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_add_a_{{singularSnake}}() - { - $this->actingAsAdmin(); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->make(); - - $this->adminPost('/{{pluralKebab}}', [ - // factory inputs - ]) - ->assertSessionMissing('errors') - ->assertStatus(403); - - $this->assertDatabaseMissing('{{tableName}}', [ - // factory db values - ]); - } - - #[Test] - public function it_can_show_the_edit_{{singularSnake}}_page() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_{{pluralSnake}}'); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->adminGet("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}/edit") - ->assertStatus(200) - ->assertSee(${{singularSnake}}->{{nameField}}); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_see_the_edit_{{singularSnake}}_page() - { - $this->actingAsAdmin(); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->adminGet("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}/edit") - ->assertStatus(403) - ->assertDontSee(${{singularSnake}}->{{nameField}}); - } - - #[Test] - public function it_can_update_a_{{singularSnake}}() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_{{pluralSnake}}'); - - // old foreign key models - ${{singularSnake}} = {{singularStudly}}::factory()->create([ - // correct factory values - ]); - - // new foreign key models - $this->adminPatch("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}", [ - // different correct inputs - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $this->assertDatabaseHas('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - // different correct db values - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_update_a_{{singularSnake}}() - { - $this->actingAsAdmin(); - - // old foreign key models - ${{singularSnake}} = {{singularStudly}}::factory()->create([ - // correct factory values - ]); - - // new foreign key models - $this->adminPatch("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}", [ - // different correct inputs - ]) - ->assertSessionMissing('errors') - ->assertStatus(403); - - $this->assertDatabaseHas('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - // correct db values - ]); - } - - #[Test] - public function it_can_show_a_{{singularSnake}}() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_{{pluralSnake}}'); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->adminGet("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}") - ->assertStatus(200) - ->assertSee(${{singularSnake}}->{{nameField}}); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_view_a_{{singularSnake}}() - { - $this->actingAsAdmin(); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->adminGet("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}") - ->assertStatus(403) - ->assertDontSee(${{singularSnake}}->{{nameField}}); - } - - #[Test] - public function it_can_soft_delete_a_{{singularSnake}}() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('delete_{{pluralSnake}}'); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->assertDatabaseHas('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - - $this->adminDelete("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}") - ->assertRedirect(); - - $this->assertSoftDeleted('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_soft_delete_a_{{singularSnake}}() - { - $this->actingAsAdmin(); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - - $this->assertDatabaseHas('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - - $this->adminDelete("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}") - ->assertStatus(403); - - $this->assertDatabaseHas('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - } - - #[Test] - public function it_can_force_delete_a_{{singularSnake}}() - { - $this->withoutExceptionHandling(); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - ${{singularSnake}}->delete(); - - // sanity check - $this->assertSoftDeleted('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - - $this->actingAsAdmin('force_delete_{{pluralSnake}}'); - - $this->adminDelete("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}/force-delete") - ->assertRedirect(); - - $this->assertDatabaseMissing('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_force_delete_a_{{singularSnake}}() - { - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - ${{singularSnake}}->delete(); - - // sanity check - $this->assertSoftDeleted('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - - $this->actingAsAdmin(); - - $this->adminDelete("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}/force-delete") - ->assertStatus(403); - - $this->assertSoftDeleted('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - } - - #[Test] - public function it_can_restore_a_soft_deleted_{{singularSnake}}() - { - $this->withoutExceptionHandling(); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - ${{singularSnake}}->delete(); - - // sanity check - $this->assertSoftDeleted('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - - $this->actingAsAdmin('delete_{{pluralSnake}}'); - - $this->adminPost("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}/restore") - ->assertRedirect(); - - $this->assertDatabaseHas('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - 'deleted_at' => null, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_restore_a_{{singularSnake}}() - { - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - ${{singularSnake}}->delete(); - - // sanity check - $this->assertSoftDeleted('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - - $this->actingAsAdmin(); - - $this->adminPost("/{{pluralKebab}}/{${{singularSnake}}->{{keyName}}}/restore") - ->assertStatus(403); - - $this->assertSoftDeleted('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - } - - #[Test] - public function it_can_list_soft_deleted_{{pluralSnake}}() - { - $this->withoutExceptionHandling(); - - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - ${{singularSnake}}->delete(); - - // sanity check - $this->assertSoftDeleted('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - - $this->actingAsAdmin('delete_{{pluralSnake}}'); - - $this->adminGet("/{{pluralKebab}}/trash") - ->assertStatus(200) - ->assertSee(${{singularSnake}}->{{nameField}}); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_list_soft_deleted_{{pluralSnake}}() - { - ${{singularSnake}} = $this->getFactory({{singularStudly}}::class)->create(); - ${{singularSnake}}->delete(); - - // sanity check - $this->assertSoftDeleted('{{tableName}}', [ - '{{keyName}}' => ${{singularSnake}}->{{keyName}}, - ]); - - $this->actingAsAdmin(); - - $this->adminGet("/{{pluralKebab}}/trash") - ->assertStatus(403) - ->assertDontSee(${{singularSnake}}->{{nameField}}); - } - - #[Test] - public function it_can_bulk_soft_delete_{{pluralSnake}}() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin(['view_{{pluralSnake}}', 'delete_{{pluralSnake}}']); - - ${{pluralSnake}} = $this->getFactory({{singularStudly}}::class)->count(3)->create(); - - ${{pluralSnake}} = ${{pluralSnake}}->map(function (${{singularSnake}}) { - return collect(${{singularSnake}}->toArray()) - ->only('{{keyName}}') - ->all(); - }); - - ${{pluralSnake}}->each(function (${{singularSnake}}) { - $this->assertDatabaseHas('{{tableName}}', ${{singularSnake}}); - }); - - ${{pluralSnake}}_to_delete = ${{pluralSnake}}->slice(1); - - $this->adminPatch('/{{pluralKebab}}', [ - '{{pluralSnake}}' => ${{pluralSnake}}_to_delete->pluck('{{keyName}}')->all(), - 'action' => 'delete', - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - ${{pluralSnake}}_to_delete->each(function (${{singularSnake}}) { - $this->assertSoftDeleted('{{tableName}}', ${{singularSnake}}); - }); - } - - #[Test] - public function it_does_not_allow_unauthorized_users_to_bulk_soft_delete_{{pluralSnake}}() - { - $this->actingAsAdmin('view_{{pluralSnake}}'); - - ${{pluralSnake}} = $this->getFactory({{singularStudly}}::class)->count(3)->create(); - - ${{pluralSnake}} = ${{pluralSnake}}->map(function (${{singularSnake}}) { - return collect(${{singularSnake}}->toArray()) - ->only('{{keyName}}') - ->all(); - }); - - ${{pluralSnake}}->each(function (${{singularSnake}}) { - $this->assertDatabaseHas('{{tableName}}', ${{singularSnake}}); - }); - - ${{pluralSnake}}_to_delete = ${{pluralSnake}}->slice(1); - - $this->adminPatch('/{{pluralKebab}}', [ - '{{pluralSnake}}' => ${{pluralSnake}}_to_delete->pluck('{{keyName}}')->all(), - 'action' => 'delete', - ]) - ->assertStatus(403) - ->assertSessionMissing('errors'); - - ${{pluralSnake}}_to_delete->each(function (${{singularSnake}}) { - $this->assertDatabaseHas('{{tableName}}', ${{singularSnake}}); - }); - } - - #[Test] - public function it_can_validate_{{singularSnake}}_inputs() - { - $this->actingAsAdmin('edit_{{pluralSnake}}'); - - $this->adminPost('/{{pluralKebab}}', [ - // wrong inputs - ]) - ->assertRedirect() - ->assertSessionHasErrors([ - // errors - ]); - - $this->assertDatabaseMissing('{{tableName}}', [ - // wrong db values - ]); - } -} diff --git a/stubs/views/blade/create.blade.stub b/stubs/views/blade/create.blade.stub new file mode 100644 index 0000000..50c6307 --- /dev/null +++ b/stubs/views/blade/create.blade.stub @@ -0,0 +1,12 @@ +@extends('{{ layout }}') + +@section('title', __('{{ title }}')) + +@section('{{ section }}') +
+ {{ breadcrumbs }} +

{{ __('{{ title }}') }}

+
+ + {{ $form }} +@endsection diff --git a/stubs/views/blade/edit.blade.stub b/stubs/views/blade/edit.blade.stub new file mode 100644 index 0000000..81acec3 --- /dev/null +++ b/stubs/views/blade/edit.blade.stub @@ -0,0 +1,18 @@ +@extends('{{ layout }}') + +@section('title', __('{{ title }}')) + +@section('{{ section }}') +
+
+ {{ breadcrumbs }} +

{{ __('{{ title }}') }}

+
+ + + {{ __('View') }} + +
+ + {{ $form }} +@endsection diff --git a/stubs/views/blade/index.blade.stub b/stubs/views/blade/index.blade.stub new file mode 100644 index 0000000..c5045fb --- /dev/null +++ b/stubs/views/blade/index.blade.stub @@ -0,0 +1,48 @@ +@extends('{{ layout }}') + +@section('title', __('{{ title }}')) + +@section('{{ section }}') +
+
+ {{ breadcrumbs }} +

{{ __('{{ title }}') }}

+
+ + @can('create', {{ modelClass }}::class) + + {{ __('{{ newLabel }}') }} + + @endcan +
+ + + + + + + + + + {{ headers }} + + + + @forelse(${{ plural }} as ${{ variable }}) + + {{ cells }} + + @empty + + {{ $filters->hasActiveFilters() ? __('No {{ pluralLower }} match these filters.') : __('No {{ pluralLower }} yet.') }} + + @endforelse + + + @if(${{ plural }}->hasPages()) + + + + @endif + +@endsection diff --git a/stubs/views/blade/show.blade.stub b/stubs/views/blade/show.blade.stub new file mode 100644 index 0000000..6992b70 --- /dev/null +++ b/stubs/views/blade/show.blade.stub @@ -0,0 +1,23 @@ +@extends('{{ layout }}') + +@section('title', ${{ variable }}->{{ nameField }}) + +@section('{{ section }}') +
+
+ {{ breadcrumbs }} +

{{ ${{ variable }}->{{ nameField }} }}

+
+ +
+ {{ actions }} +
+
+ + {{ trashedAlert }} + + + {{ entries }} + + {{ media }} +@endsection diff --git a/stubs/views/model-soft-deletes/_actions.blade.stub b/stubs/views/model-soft-deletes/_actions.blade.stub deleted file mode 100644 index 451ac44..0000000 --- a/stubs/views/model-soft-deletes/_actions.blade.stub +++ /dev/null @@ -1,48 +0,0 @@ -
- @if(isset(${{singularSnake}})) - @can('delete', ${{singularSnake}}) - - {{ __('Delete') }} - - @endcan - - @can('viewLogs', ${{singularSnake}}) - - {{ __('View Logs') }} - - @endcan - - @can('update', ${{singularSnake}}) - - {{ __('Edit') }} - - @endcan - - @can('view', ${{singularSnake}}) - - {{ __('View') }} - - @endcan - @endif - - @can('create', App\Models\{{singularStudly}}::class) - - {{ __('Add New') }} - - @endcan - - @can('viewTrash', App\Models\{{singularStudly}}::class) - - {{ __('Trash') }} - - @endcan - - @can('viewAny', App\Models\{{singularStudly}}::class) - - {{ __('View All') }} - - @endcan -
diff --git a/stubs/views/model-soft-deletes/_filter.blade.stub b/stubs/views/model-soft-deletes/_filter.blade.stub deleted file mode 100644 index 900a9a6..0000000 --- a/stubs/views/model-soft-deletes/_filter.blade.stub +++ /dev/null @@ -1,30 +0,0 @@ - -
-
- -
-
- -
-
- -
-
- -
-
-
-
- -
-
- -
-
-
diff --git a/stubs/views/model-soft-deletes/_list.blade.stub b/stubs/views/model-soft-deletes/_list.blade.stub deleted file mode 100644 index 976784a..0000000 --- a/stubs/views/model-soft-deletes/_list.blade.stub +++ /dev/null @@ -1,48 +0,0 @@ -@foreach(${{pluralSnake}} as ${{singularSnake}}) - - - - {!! ${{singularSnake}}->admin_link !!} -
- {{ __('ID: :id', ['id' => ${{singularSnake}}->{{keyName}}]) }} - - @if(${{singularSnake}}->trashed()) - @can('forceDelete', ${{singularSnake}}) - - {{ __('Delete Permanently') }} - - @endcan - - @can('restore', ${{singularSnake}}) - - {{ __('Restore') }} - - @endcan - @else - @can('view', ${{singularSnake}}) - - {{ __('View') }} - - @endcan - - @can('update', ${{singularSnake}}) - - {{ __('Edit') }} - - @endcan - - @can('delete', ${{singularSnake}}) - - {{ __('Delete') }} - - @endcan - @endif -
-
- // columns - -
-@endforeach diff --git a/stubs/views/model-soft-deletes/index.blade.stub b/stubs/views/model-soft-deletes/index.blade.stub deleted file mode 100644 index a01d6ae..0000000 --- a/stubs/views/model-soft-deletes/index.blade.stub +++ /dev/null @@ -1,36 +0,0 @@ -@extends('admin.{{pluralKebab}}.{{pluralKebab}}') - -@section('page-title') - {{ if_route('admin.{{pluralKebab}}.trash') ? __('Deleted {{pluralTitle}}') : __('{{pluralTitle}}') }} -@endsection - -@section('page-subheading') - {{ $title }} -@endsection - -@section('content') - @if(${{pluralSnake}}->isNotEmpty() || App\Models\{{singularStudly}}::exists()) -
- - @include('admin.{{pluralKebab}}._filter') - - - @include('admin.{{pluralKebab}}._table', [ - 'no_bulk' => $trashed, - 'no_checkbox' => $trashed, - ]) -
- @else - - @endif -@endsection diff --git a/stubs/views/model/_actions.blade.stub b/stubs/views/model/_actions.blade.stub deleted file mode 100644 index 82adda4..0000000 --- a/stubs/views/model/_actions.blade.stub +++ /dev/null @@ -1,41 +0,0 @@ -
- @if(isset(${{singularSnake}})) - @can('delete', ${{singularSnake}}) - - {{ __('Delete') }} - - @endcan - - @can('viewLogs', ${{singularSnake}}) - - {{ __('View Logs') }} - - @endcan - - @can('update', ${{singularSnake}}) - - {{ __('Edit') }} - - @endcan - - @can('view', ${{singularSnake}}) - - {{ __('View') }} - - @endcan - @endif - - @can('create', App\Models\{{singularStudly}}::class) - - {{ __('Add New') }} - - @endcan - - @can('viewAny', App\Models\{{singularStudly}}::class) - - {{ __('View All') }} - - @endcan -
diff --git a/stubs/views/model/_bulk.blade.stub b/stubs/views/model/_bulk.blade.stub deleted file mode 100644 index 0e19612..0000000 --- a/stubs/views/model/_bulk.blade.stub +++ /dev/null @@ -1,9 +0,0 @@ -@php - $actions = []; - - if (auth()->user()->can('delete_{{pluralSnake}}')) { - $actions['delete'] = __('Delete'); - } -@endphp - - diff --git a/stubs/views/model/_details.blade.stub b/stubs/views/model/_details.blade.stub deleted file mode 100644 index 8f98965..0000000 --- a/stubs/views/model/_details.blade.stub +++ /dev/null @@ -1,3 +0,0 @@ - - // entries - diff --git a/stubs/views/model/_filter.blade.stub b/stubs/views/model/_filter.blade.stub deleted file mode 100644 index 81a21af..0000000 --- a/stubs/views/model/_filter.blade.stub +++ /dev/null @@ -1,30 +0,0 @@ - -
-
- -
-
- -
-
- -
-
- -
-
-
-
- -
-
- -
-
-
diff --git a/stubs/views/model/_form.blade.stub b/stubs/views/model/_form.blade.stub deleted file mode 100644 index 7669015..0000000 --- a/stubs/views/model/_form.blade.stub +++ /dev/null @@ -1,13 +0,0 @@ - - - - {{ __('Save') }} - - - - {{ __('Cancel') }} - - - - -@include('admin.components.floating-submit') diff --git a/stubs/views/model/_list.blade.stub b/stubs/views/model/_list.blade.stub deleted file mode 100644 index 4b62e7a..0000000 --- a/stubs/views/model/_list.blade.stub +++ /dev/null @@ -1,32 +0,0 @@ -@foreach(${{pluralSnake}} as ${{singularSnake}}) - - - - {!! ${{singularSnake}}->admin_link !!} -
- {{ __('ID: :id', ['id' => ${{singularSnake}}->{{keyName}}]) }} - - @can('view', ${{singularSnake}}) - - {{ __('View') }} - - @endcan - - @can('update', ${{singularSnake}}) - - {{ __('Edit') }} - - @endcan - - @can('delete', ${{singularSnake}}) - - {{ __('Delete') }} - - @endcan -
-
- // columns - -
-@endforeach diff --git a/stubs/views/model/_table.blade.stub b/stubs/views/model/_table.blade.stub deleted file mode 100644 index 9a8685c..0000000 --- a/stubs/views/model/_table.blade.stub +++ /dev/null @@ -1,35 +0,0 @@ - - - @if(empty($no_bulk)) - - @include('admin.{{pluralKebab}}._bulk') - - @endif - - - - // titles - - - - @if(${{pluralSnake}}->isEmpty()) - - {{ __('No matching {{pluralLower}} found.') }} - - @else - @include('admin.{{pluralKebab}}._list') - @endif - - - @if(empty($no_pagination)) - - {{ ${{pluralSnake}}->links('forms::material-admin-26.pagination') }} - - @endif - - diff --git a/stubs/views/model/create.blade.stub b/stubs/views/model/create.blade.stub deleted file mode 100644 index 874f761..0000000 --- a/stubs/views/model/create.blade.stub +++ /dev/null @@ -1,9 +0,0 @@ -@extends('admin.{{pluralKebab}}.{{pluralKebab}}') - -@section('page-title', __('New {{singularTitle}}')) - -@section('content') - - @include('admin.{{pluralKebab}}._form') - -@endsection diff --git a/stubs/views/model/edit.blade.stub b/stubs/views/model/edit.blade.stub deleted file mode 100644 index a8f5514..0000000 --- a/stubs/views/model/edit.blade.stub +++ /dev/null @@ -1,9 +0,0 @@ -@extends('admin.{{pluralKebab}}.{{pluralKebab}}') - -@section('page-title', __('Edit {{singularTitle}}')) - -@section('content') - - @include('admin.{{pluralKebab}}._form') - -@endsection diff --git a/stubs/views/model/index.blade.stub b/stubs/views/model/index.blade.stub deleted file mode 100644 index 920d817..0000000 --- a/stubs/views/model/index.blade.stub +++ /dev/null @@ -1,29 +0,0 @@ -@extends('admin.{{pluralKebab}}.{{pluralKebab}}') - -@section('page-subheading') - {{ $title }} -@endsection - -@section('content') - @if(${{pluralSnake}}->isNotEmpty() || App\Models\{{singularStudly}}::exists()) -
- - @include('admin.{{pluralKebab}}._filter') - - - @include('admin.{{pluralKebab}}._table') -
- @else - - @endif -@endsection diff --git a/stubs/views/model/model.blade.stub b/stubs/views/model/model.blade.stub deleted file mode 100644 index bce8094..0000000 --- a/stubs/views/model/model.blade.stub +++ /dev/null @@ -1,12 +0,0 @@ -@extends('layouts.admin') - -@section('title', '{{pluralTitle}}') -@section('page-title', __('{{pluralTitle}}')) - -@section('top-search') - -@endsection - -@section('model-actions') - @include('admin.{{pluralKebab}}._actions') -@endsection diff --git a/stubs/views/model/show.blade.stub b/stubs/views/model/show.blade.stub deleted file mode 100644 index 3f88b34..0000000 --- a/stubs/views/model/show.blade.stub +++ /dev/null @@ -1,7 +0,0 @@ -@extends('admin.{{pluralKebab}}.{{pluralKebab}}') - -@section('page-title', __('View {{singularTitle}}')) - -@section('content') - @include('admin.{{pluralKebab}}._details') -@endsection diff --git a/tests/Concerns/AssertsSnapshots.php b/tests/Concerns/AssertsSnapshots.php new file mode 100644 index 0000000..cac6bc9 --- /dev/null +++ b/tests/Concerns/AssertsSnapshots.php @@ -0,0 +1,29 @@ +fail("The snapshot {$name} is missing: run the tests with GENERATORS_UPDATE_SNAPSHOTS=1."); + } + + $this->assertEquals(file_get_contents($path), $actual, "The output differs from the snapshot {$name}."); + } +} diff --git a/tests/Feature/Commands/Auth/Controllers/GenerateAuthConfirmPasswordControllerCommandTest.php b/tests/Feature/Commands/Auth/Controllers/GenerateAuthConfirmPasswordControllerCommandTest.php deleted file mode 100644 index 01bd32f..0000000 --- a/tests/Feature/Commands/Auth/Controllers/GenerateAuthConfirmPasswordControllerCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('Controllers/Customer/Auth/ConfirmPasswordController.php'); - - $this->artisan('generate:auth_confirm_password_controller', ['table' => 'customers']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_auth_confirm_password_controller_file(): void - { - $expected_path = $this->app->path('Http/Controllers/Customer/Auth/ConfirmPasswordController.php'); - $expected_content = $this->getTestStubContents('Controllers/Customer/Auth/ConfirmPasswordController.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:auth_confirm_password_controller', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/Auth/Controllers/GenerateAuthControllerCommandTest.php b/tests/Feature/Commands/Auth/Controllers/GenerateAuthControllerCommandTest.php deleted file mode 100644 index a9c7613..0000000 --- a/tests/Feature/Commands/Auth/Controllers/GenerateAuthControllerCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('Controllers/CustomersController.php'); - - $this->artisan('generate:auth_controller', ['table' => 'customers']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_auth_controller_file(): void - { - $expected_path = $this->app->path('Http/Controllers/Admin/CustomersController.php'); - $expected_content = $this->getTestStubContents('Controllers/CustomersController.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:auth_controller', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/Auth/Controllers/GenerateAuthForgotPasswordControllerCommandTest.php b/tests/Feature/Commands/Auth/Controllers/GenerateAuthForgotPasswordControllerCommandTest.php deleted file mode 100644 index 4a71afa..0000000 --- a/tests/Feature/Commands/Auth/Controllers/GenerateAuthForgotPasswordControllerCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('Controllers/Customer/Auth/ForgotPasswordController.php'); - - $this->artisan('generate:auth_forgot_password_controller', ['table' => 'customers']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_auth_forgot_password_controller_file(): void - { - $expected_path = $this->app->path('Http/Controllers/Customer/Auth/ForgotPasswordController.php'); - $expected_content = $this->getTestStubContents('Controllers/Customer/Auth/ForgotPasswordController.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:auth_forgot_password_controller', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/Auth/Controllers/GenerateAuthHomeControllerCommandTest.php b/tests/Feature/Commands/Auth/Controllers/GenerateAuthHomeControllerCommandTest.php deleted file mode 100644 index aac74ee..0000000 --- a/tests/Feature/Commands/Auth/Controllers/GenerateAuthHomeControllerCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('Controllers/Customer/HomeController.php'); - - $this->artisan('generate:auth_home_controller', ['table' => 'customers']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_auth_home_controller_file(): void - { - $expected_path = $this->app->path('Http/Controllers/Customer/HomeController.php'); - $expected_content = $this->getTestStubContents('Controllers/Customer/HomeController.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:auth_home_controller', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/Auth/Controllers/GenerateAuthLoginControllerCommandTest.php b/tests/Feature/Commands/Auth/Controllers/GenerateAuthLoginControllerCommandTest.php deleted file mode 100644 index 36f8c5d..0000000 --- a/tests/Feature/Commands/Auth/Controllers/GenerateAuthLoginControllerCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('Controllers/Customer/Auth/LoginController.php'); - - $this->artisan('generate:auth_login_controller', ['table' => 'customers']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_auth_login_controller_file(): void - { - $expected_path = $this->app->path('Http/Controllers/Customer/Auth/LoginController.php'); - $expected_content = $this->getTestStubContents('Controllers/Customer/Auth/LoginController.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:auth_login_controller', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/Auth/Controllers/GenerateAuthRegisterControllerCommandTest.php b/tests/Feature/Commands/Auth/Controllers/GenerateAuthRegisterControllerCommandTest.php deleted file mode 100644 index 0cfb26c..0000000 --- a/tests/Feature/Commands/Auth/Controllers/GenerateAuthRegisterControllerCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('Controllers/Customer/Auth/RegisterController.php'); - - $this->artisan('generate:auth_register_controller', ['table' => 'customers']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_auth_register_controller_file(): void - { - $expected_path = $this->app->path('Http/Controllers/Customer/Auth/RegisterController.php'); - $expected_content = $this->getTestStubContents('Controllers/Customer/Auth/RegisterController.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:auth_register_controller', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/Auth/Controllers/GenerateAuthResetPasswordControllerCommandTest.php b/tests/Feature/Commands/Auth/Controllers/GenerateAuthResetPasswordControllerCommandTest.php deleted file mode 100644 index 943feb0..0000000 --- a/tests/Feature/Commands/Auth/Controllers/GenerateAuthResetPasswordControllerCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('Controllers/Customer/Auth/ResetPasswordController.php'); - - $this->artisan('generate:auth_reset_password_controller', ['table' => 'customers']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_auth_reset_password_controller_file(): void - { - $expected_path = $this->app->path('Http/Controllers/Customer/Auth/ResetPasswordController.php'); - $expected_content = $this->getTestStubContents('Controllers/Customer/Auth/ResetPasswordController.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:auth_reset_password_controller', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/Auth/Controllers/GenerateAuthUpdatePasswordControllerCommandTest.php b/tests/Feature/Commands/Auth/Controllers/GenerateAuthUpdatePasswordControllerCommandTest.php deleted file mode 100644 index 880437f..0000000 --- a/tests/Feature/Commands/Auth/Controllers/GenerateAuthUpdatePasswordControllerCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('Controllers/Customer/Auth/UpdatePasswordController.php'); - - $this->artisan('generate:auth_update_password_controller', ['table' => 'customers']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_auth_update_password_controller_file(): void - { - $expected_path = $this->app->path('Http/Controllers/Customer/Auth/UpdatePasswordController.php'); - $expected_content = $this->getTestStubContents('Controllers/Customer/Auth/UpdatePasswordController.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:auth_update_password_controller', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/Auth/Controllers/GenerateAuthVerificationControllerCommandTest.php b/tests/Feature/Commands/Auth/Controllers/GenerateAuthVerificationControllerCommandTest.php deleted file mode 100644 index 394454d..0000000 --- a/tests/Feature/Commands/Auth/Controllers/GenerateAuthVerificationControllerCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('Controllers/Customer/Auth/VerificationController.php'); - - $this->artisan('generate:auth_verification_controller', ['table' => 'customers']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_auth_verification_controller_file(): void - { - $expected_path = $this->app->path('Http/Controllers/Customer/Auth/VerificationController.php'); - $expected_content = $this->getTestStubContents('Controllers/Customer/Auth/VerificationController.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:auth_verification_controller', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/Auth/GenerateAuthConfigCommandTest.php b/tests/Feature/Commands/Auth/GenerateAuthConfigCommandTest.php deleted file mode 100644 index ae7556e..0000000 --- a/tests/Feature/Commands/Auth/GenerateAuthConfigCommandTest.php +++ /dev/null @@ -1,70 +0,0 @@ -deleteFile($this->app->configPath('auth.php')); - } - - protected function tearDown(): void - { - $this->deleteFile($this->app->configPath('auth.php')); - - parent::tearDown(); - } - - public function test_it_can_generate_auth_config_output(): void - { - $expected_content = ''; - - $expected_content .= "// guards\n"; - $expected_content .= $this->getTestStubContents('config/auth/_guards.stub'); - - $expected_content .= "// providers\n"; - $expected_content .= $this->getTestStubContents('config/auth/_providers.stub'); - - $expected_content .= "// passwords\n"; - $expected_content .= $this->getTestStubContents('config/auth/_passwords.stub'); - - $expected_content .= "// passport-guards\n"; - $expected_content .= $this->getTestStubContents('config/auth/_passport-guards.stub'); - - $expected_content .= "// default-guard\n"; - $expected_content .= $this->getTestStubContents('config/auth/_default-guard.stub'); - - $expected_content .= "// default-passwords\n"; - $expected_content .= $this->getTestStubContents('config/auth/_default-passwords.stub'); - - $this->artisan('generate:auth_config', ['table' => 'customers']) - ->expectsOutput($expected_content); - } - - public function test_it_can_append_config_to_an_existing_auth_config_file(): void - { - $expected_path = $this->app->configPath('auth.php'); - - $this->copyFile($this->getTestStubPath('config/auth/skeleton-auth.php'), $expected_path); - $expected_content = $this->getTestStubContents('config/auth/models-auth.php'); - - $this->artisan('generate:auth_config', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - - $this->artisan('generate:auth_config', ['table' => 'public_users', '--create' => true]) - ->assertSuccessful(); - - $this->assertFileExists($expected_path); - - $actual_content = $this->getGeneratedFileContents($expected_path); - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Feature/Commands/Auth/GenerateAuthFactoryCommandTest.php b/tests/Feature/Commands/Auth/GenerateAuthFactoryCommandTest.php deleted file mode 100644 index a29e937..0000000 --- a/tests/Feature/Commands/Auth/GenerateAuthFactoryCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('factories/CustomerFactory.php'); - - $this->artisan('generate:auth_factory', ['table' => 'customers']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_auth_factory_file(): void - { - $expected_path = $this->app->databasePath('factories/CustomerFactory.php'); - $expected_content = $this->getTestStubContents('factories/CustomerFactory.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:auth_factory', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/Auth/GenerateAuthModelCommandTest.php b/tests/Feature/Commands/Auth/GenerateAuthModelCommandTest.php deleted file mode 100644 index b926e20..0000000 --- a/tests/Feature/Commands/Auth/GenerateAuthModelCommandTest.php +++ /dev/null @@ -1,94 +0,0 @@ -deleteFile($this->app->path('Models/Customer.php')); - $this->deleteFile($this->app->path('Models/PublicUser.php')); - - // setup skeleton service provider - $this->copyFile( - $this->getTestStubPath('Providers/SkeletonAppServiceProvider.php'), - $this->app->path('Providers/AppServiceProvider.php') - ); - } - - protected function tearDown(): void - { - $this->deleteFile($this->app->path('Models/Customer.php')); - $this->deleteFile($this->app->path('Models/PublicUser.php')); - - // setup standard service provider - $this->copyFile( - $this->getTestStubPath('Providers/AppServiceProvider.php'), - $this->app->path('Providers/AppServiceProvider.php') - ); - - parent::tearDown(); - } - - public function test_it_can_generate_auth_model_output(): void - { - $expected_content = $this->getTestStubContents('Models/Customer.php'); - - $this->artisan('generate:auth_model', ['table' => 'customers']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_a_new_auth_model_file(): void - { - $expected_path = $this->app->path('Models/Customer.php'); - $expected_content = $this->getTestStubContents('Models/Customer.php'); - - $this->artisan('generate:auth_model', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - - $this->assertFileExists($expected_path); - - $actual_content = $this->getGeneratedFileContents($expected_path); - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_adds_auth_causer_types_and_subject_types_and_morph_maps(): void - { - $expected_path = $this->app->path('Models/PublicUser.php'); - $expected_content = $this->getTestStubContents('Models/PublicUser.php'); - - $this->artisan('generate:auth_model', ['table' => 'public_users', '--create' => true, '--auth_name' => 'portal']) - ->assertSuccessful(); - - $this->assertFileExists($expected_path); - - $actual_content = $this->getGeneratedFileContents($expected_path); - - $this->assertEquals($expected_content, $actual_content); - - $expected_path = $this->app->path('Models/Customer.php'); - $expected_content = $this->getTestStubContents('Models/Customer.php'); - - $this->artisan('generate:auth_model', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - - $this->assertFileExists($expected_path); - - $actual_content = $this->getGeneratedFileContents($expected_path); - $this->assertEquals($expected_content, $actual_content); - - $expected_path = $this->app->path('Providers/AppServiceProvider.php'); - $expected_content = $this->getTestStubContents('Providers/AuthModelsAppServiceProvider.php'); - $actual_content = $this->getGeneratedFileContents($expected_path); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Feature/Commands/Auth/GenerateAuthPasswordResetsCommandTest.php b/tests/Feature/Commands/Auth/GenerateAuthPasswordResetsCommandTest.php deleted file mode 100644 index ef4495d..0000000 --- a/tests/Feature/Commands/Auth/GenerateAuthPasswordResetsCommandTest.php +++ /dev/null @@ -1,42 +0,0 @@ -getTestStubContents('migrations/create_customer_password_reset_tokens_table.php'); - - $this->artisan('generate:auth_password_resets', ['table' => 'customers']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_password_resets_migration_file(): void - { - $this->travelTo('2024-04-23 12:43:31'); - - $expected_path = $this->app->databasePath('migrations/2024_04_23_124331_create_customer_password_reset_tokens_table.php'); - $expected_content = $this->getTestStubContents('migrations/create_customer_password_reset_tokens_table.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:auth_password_resets', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/Auth/GenerateAuthPermissionsCommandTest.php b/tests/Feature/Commands/Auth/GenerateAuthPermissionsCommandTest.php deleted file mode 100644 index 4ca3fce..0000000 --- a/tests/Feature/Commands/Auth/GenerateAuthPermissionsCommandTest.php +++ /dev/null @@ -1,90 +0,0 @@ -deleteFile($this->app->databasePath('seeders/PermissionsSeeder.php')); - } - - protected function tearDown(): void - { - $this->deleteFile($this->app->databasePath('seeders/PermissionsSeeder.php')); - - parent::tearDown(); - } - - public function test_it_can_generate_auth_permissions_output(): void - { - $expected_content = $this->getTestStubContents('seeders/_authPermissions.stub'); - - $this->artisan('generate:auth_permissions', ['table' => 'customers']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_a_new_auth_permissions_file(): void - { - $expected_path = $this->app->databasePath('seeders/PermissionsSeeder.php'); - $expected_content = $this->getTestStubContents('seeders/AuthPermissionsSeederNew.php'); - - $this->artisan('generate:auth_permissions', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - - $this->artisan('generate:auth_permissions', ['table' => 'public_users', '--create' => true]) - ->assertSuccessful(); - - $this->assertFileExists($expected_path); - - $actual_content = $this->getGeneratedFileContents($expected_path); - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_append_permissions_to_an_existing_auth_permissions_seeder(): void - { - $expected_path = $this->app->databasePath('seeders/PermissionsSeeder.php'); - - $this->copyFile($this->getTestStubPath('seeders/PermissionsSeeder.php'), $expected_path); - $expected_content = $this->getTestStubContents('seeders/AuthPermissionsSeederExisting.php'); - - $this->artisan('generate:auth_permissions', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - - $this->artisan('generate:auth_permissions', ['table' => 'public_users', '--create' => true]) - ->assertSuccessful(); - - $this->assertFileExists($expected_path); - - $actual_content = $this->getGeneratedFileContents($expected_path); - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_does_not_duplicate_permissions_when_the_generate_command_is_called_multiple_times(): void - { - $expected_path = $this->app->databasePath('seeders/PermissionsSeeder.php'); - - $this->copyFile($this->getTestStubPath('seeders/PermissionsSeeder.php'), $expected_path); - $expected_content = $this->getTestStubContents('seeders/AuthPermissionsSeederExisting.php'); - - $this->artisan('generate:auth_permissions', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - - $this->artisan('generate:auth_permissions', ['table' => 'public_users', '--create' => true]) - ->assertSuccessful(); - - $this->assertFileExists($expected_path); - - $actual_content = $this->getGeneratedFileContents($expected_path); - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Feature/Commands/Auth/GenerateAuthPolicyCommandTest.php b/tests/Feature/Commands/Auth/GenerateAuthPolicyCommandTest.php deleted file mode 100644 index dc9801f..0000000 --- a/tests/Feature/Commands/Auth/GenerateAuthPolicyCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('Policies/CustomerPolicy.php'); - - $this->artisan('generate:auth_policy', ['table' => 'customers']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_auth_policy_file(): void - { - $expected_path = $this->app->path('Policies/CustomerPolicy.php'); - $expected_content = $this->getTestStubContents('Policies/CustomerPolicy.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:auth_policy', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/Auth/GenerateAuthRequestCommandTest.php b/tests/Feature/Commands/Auth/GenerateAuthRequestCommandTest.php deleted file mode 100644 index 90c610a..0000000 --- a/tests/Feature/Commands/Auth/GenerateAuthRequestCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('Requests/CustomersRequest.php'); - - $this->artisan('generate:auth_request', ['table' => 'customers']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_auth_model_request_file(): void - { - $expected_path = $this->app->path('Http/Requests/CustomersRequest.php'); - $expected_content = $this->getTestStubContents('Requests/CustomersRequest.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:auth_request', ['table' => 'customers', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/BaseGenerateCommandTest.php b/tests/Feature/Commands/BaseGenerateCommandTest.php deleted file mode 100644 index 1e9db76..0000000 --- a/tests/Feature/Commands/BaseGenerateCommandTest.php +++ /dev/null @@ -1,83 +0,0 @@ -expectException(RuntimeException::class); - $this->expectExceptionMessage('Not enough arguments (missing: "table")'); - - $this->artisan('generate:factory'); - } - - public function test_it_does_not_accept_multiple_table_names(): void - { - $this->expectException(MultipleTablesSuppliedException::class); - - $this->artisan('generate:factory', ['table' => 'categories,orders']); - } - - public function test_it_checks_if_the_table_exists(): void - { - $this->expectException(TableDoesNotExistException::class); - - $this->artisan('generate:factory', ['table' => 'none_existing_table']); - } - - public function test_it_checks_if_the_columns_exists(): void - { - $this->expectException(ColumnDoesNotExistException::class); - - $this->artisan('generate:factory', ['table' => 'products', '--columns' => 'name,none_existing_field']); - } - - public function test_it_does_not_create_existing_files_without_the_force_option(): void - { - $expected_path = $this->app->databasePath('factories/CategoryFactory.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) { - $mock->shouldReceive('exists') - ->once() - ->andReturnTrue(); - - $mock->shouldNotReceive('makeDirectory'); - - $mock->shouldNotReceive('put'); - }); - - $this->artisan('generate:factory', ['table' => 'categories', '--create' => true]) - ->expectsOutput($expected_path . ' already exists!'); - } - - public function test_it_over_writes_existing_files_with_the_force_option(): void - { - $this->partialMock(Filesystem::class, function (MockInterface $mock) { - $mock->shouldReceive('exists') - ->once() - ->andReturnTrue(); - - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->andReturnTrue(); - }); - - $this->artisan('generate:factory', ['table' => 'categories', '--create' => true, '--force' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/GenerateAllCommandTest.php b/tests/Feature/Commands/GenerateAllCommandTest.php deleted file mode 100644 index 0e97acc..0000000 --- a/tests/Feature/Commands/GenerateAllCommandTest.php +++ /dev/null @@ -1,102 +0,0 @@ -getCommands(); - - foreach ($commands as $command) { - // skip test and permissions - if ($command == GenerateTestCommand::class || $command == GeneratePermissionsCommand::class) { - $fake_command = $this->mock($command . '[createOutput]', function ($mock) { - $mock->shouldAllowMockingProtectedMethods() - ->shouldNotHaveReceived('createOutput'); - }); - } else { - $fake_command = $this->mock($command . '[createOutput]', function ($mock) { - $mock->shouldAllowMockingProtectedMethods() - ->shouldReceive('createOutput') - ->once() - ->with('categories', []); - }); - } - - $this->app->instance($command, $fake_command); - } - - $this->artisan('generate:all', ['table' => 'categories', '--except' => 'test,permissions']) - ->assertSuccessful(); - } - - public function test_it_can_generate_all_output(): void - { - $commands = $this->getCommands(); - - foreach ($commands as $command) { - $fake_command = $this->mock($command . '[createOutput]', function ($mock) { - $mock->shouldAllowMockingProtectedMethods() - ->shouldReceive('createOutput') - ->once() - ->with('categories', []); - }); - - $this->app->instance($command, $fake_command); - } - - $this->artisan('generate:all', ['table' => 'categories']) - ->assertSuccessful(); - } - - public function test_it_can_create_all_files(): void - { - $commands = $this->getCommands(); - - foreach ($commands as $command) { - $fake_command = $this->mock($command . '[createFiles]', function ($mock) { - $mock->shouldAllowMockingProtectedMethods() - ->shouldReceive('createFiles') - ->once() - ->with('categories', [], false, ''); - }); - - $this->app->instance($command, $fake_command); - } - - $this->artisan('generate:all', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/GenerateApiCommandTest.php b/tests/Feature/Commands/GenerateApiCommandTest.php deleted file mode 100644 index 5250ff7..0000000 --- a/tests/Feature/Commands/GenerateApiCommandTest.php +++ /dev/null @@ -1,59 +0,0 @@ -getCommands(); - - foreach ($commands as $command) { - $fake_command = $this->mock($command . '[createOutput]', function ($mock) { - $mock->shouldAllowMockingProtectedMethods() - ->shouldReceive('createOutput') - ->once() - ->with('categories', []); - }); - - $this->app->instance($command, $fake_command); - } - - $this->artisan('generate:api', ['table' => 'categories']) - ->assertSuccessful(); - } - - public function test_it_can_create_all_api_files(): void - { - $commands = $this->getCommands(); - - foreach ($commands as $command) { - $fake_command = $this->mock($command . '[createFiles]', function ($mock) { - $mock->shouldAllowMockingProtectedMethods() - ->shouldReceive('createFiles') - ->once() - ->with('categories', [], false, ''); - }); - - $this->app->instance($command, $fake_command); - } - - $this->artisan('generate:api', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/GenerateApiControllerCommandTest.php b/tests/Feature/Commands/GenerateApiControllerCommandTest.php deleted file mode 100644 index cc112fc..0000000 --- a/tests/Feature/Commands/GenerateApiControllerCommandTest.php +++ /dev/null @@ -1,80 +0,0 @@ -deleteDirectory($this->app->path('Http/Controllers/Api')); - - // setup skeleton route file - $this->copyFile( - $this->getTestStubPath('routes/skeletonApi.php'), - $this->app->basePath('routes/api.php') - ); - } - - protected function tearDown(): void - { - $this->deleteFile($this->app->path('routes/api.php')); - - $this->deleteDirectory($this->app->path('Http/Controllers/Api')); - - parent::tearDown(); - } - - public function test_it_can_generate_api_output(): void - { - $expected_content = $this->getTestStubContents('Controllers/Api/CategoriesController.php'); - - $this->artisan('generate:api_controller', ['table' => 'categories']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_api_controller_file(): void - { - $expected_path = $this->app->path('Http/Controllers/Api/CategoriesController.php'); - $expected_content = $this->getTestStubContents('Controllers/Api/CategoriesController.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:api_controller', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - } - - public function test_it_can_append_api_routes_to_an_existing_api_routes_file(): void - { - $expected_path = $this->app->basePath('routes/api.php'); - $expected_content = $this->getTestStubContents('routes/modelsApi.php'); - - $this->artisan('generate:api_controller', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - - $this->artisan('generate:api_controller', ['table' => 'products', '--create' => true]) - ->assertSuccessful(); - - $this->assertFileExists($expected_path); - - $actual_content = $this->getGeneratedFileContents($expected_path); - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Feature/Commands/GenerateApiTestCommandTest.php b/tests/Feature/Commands/GenerateApiTestCommandTest.php deleted file mode 100644 index 73dd96a..0000000 --- a/tests/Feature/Commands/GenerateApiTestCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('tests/Api/CategoriesControllerTest.stub'); - - $this->artisan('generate:api_test', ['table' => 'categories']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_test_file(): void - { - $expected_path = $this->app->basePath('tests/Feature/Controllers/Api/CategoriesControllerTest.php'); - $expected_content = $this->getTestStubContents('tests/Api/CategoriesControllerTest.stub'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:api_test', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/GenerateCommandsTest.php b/tests/Feature/Commands/GenerateCommandsTest.php new file mode 100644 index 0000000..6b6b1ac --- /dev/null +++ b/tests/Feature/Commands/GenerateCommandsTest.php @@ -0,0 +1,326 @@ +path = sys_get_temp_dir() . '/generators-' . uniqid(); + + (new Filesystem)->ensureDirectoryExists("{$this->path}/app/Providers"); + (new Filesystem)->ensureDirectoryExists("{$this->path}/routes"); + + file_put_contents("{$this->path}/app/Providers/AppServiceProvider.php", <<<'PHP' + path}/routes/web.php", "app->setBasePath($this->path); + $this->app->useAppPath("{$this->path}/app"); + $this->app->useDatabasePath("{$this->path}/database"); + $this->app->useLangPath("{$this->path}/lang"); + } + + protected function tearDown(): void + { + $path = $this->path; + + parent::tearDown(); + + (new Filesystem)->deleteDirectory($path); + } + + #[Test] + public function it_prints_the_code_without_create(): void + { + $this->artisan('generate:model', ['table' => 'categories']) + ->expectsOutputToContain('// app/Models/Category.php') + ->expectsOutputToContain('class Category extends Model') + ->assertSuccessful(); + + $this->assertFileDoesNotExist("{$this->path}/app/Models/Category.php"); + } + + #[Test] + public function it_writes_the_files_with_create(): void + { + $this->artisan('generate:all', ['table' => 'products', '--create' => true])->assertSuccessful(); + + foreach ([ + 'app/Models/Product.php', + 'database/factories/ProductFactory.php', + 'app/Policies/ProductPolicy.php', + 'app/Filters/ProductFilters.php', + 'app/BulkActions/ProductBulkActions.php', + 'app/Stencil/ProductForm.php', + 'app/Http/Controllers/Admin/ProductsController.php', + 'routes/admin.php', + 'resources/views/admin/products/index.blade.php', + 'resources/views/admin/products/show.blade.php', + 'tests/Feature/Admin/ProductsControllerTest.php', + 'lang/dv.json', + ] as $file) { + $this->assertFileExists("{$this->path}/{$file}"); + } + + $provider = file_get_contents("{$this->path}/app/Providers/AppServiceProvider.php"); + + $this->assertStringContainsString('use Javaabu\\Permissions\\Permissions;', $provider); + $this->assertStringContainsString("Permissions::resource('products', label: 'Products');", $provider); + + $routes = file_get_contents("{$this->path}/routes/admin.php"); + + $this->assertStringContainsString("Route::resource('products', ProductsController::class)->withTrashed(['show']);", $routes); + $this->assertStringContainsString("Route::bulkActions('products', ProductBulkActions::class);", $routes); + $this->assertStringContainsString("require __DIR__ . '/admin.php';", file_get_contents("{$this->path}/routes/web.php")); + + $this->assertEquals('ހޯދާ', json_decode(file_get_contents("{$this->path}/lang/dv.json"), true)['Search']); + } + + #[Test] + public function it_adds_to_merged_files_once_and_keeps_existing_files(): void + { + $this->artisan('generate:all', ['table' => 'categories', '--create' => true])->assertSuccessful(); + + file_put_contents("{$this->path}/app/Models/Category.php", 'artisan('generate:all', ['table' => 'categories', '--create' => true]) + ->expectsOutputToContain('exists') + ->assertSuccessful(); + + $this->assertEquals('path}/app/Models/Category.php")); + $this->assertEquals(1, substr_count(file_get_contents("{$this->path}/routes/admin.php"), "Route::resource('categories'")); + $this->assertEquals(1, substr_count(file_get_contents("{$this->path}/app/Providers/AppServiceProvider.php"), "Permissions::resource('categories'")); + } + + #[Test] + public function it_overwrites_with_force(): void + { + $this->artisan('generate:model', ['table' => 'categories', '--create' => true])->assertSuccessful(); + + file_put_contents("{$this->path}/app/Models/Category.php", 'artisan('generate:model', ['table' => 'categories', '--create' => true, '--force' => true])->assertSuccessful(); + + $this->assertStringContainsString('class Category extends Model', file_get_contents("{$this->path}/app/Models/Category.php")); + } + + #[Test] + public function it_skips_parts_with_except(): void + { + $this->artisan('generate:all', ['table' => 'categories', '--create' => true, '--except' => 'views,test,routes'])->assertSuccessful(); + + $this->assertFileExists("{$this->path}/app/Models/Category.php"); + $this->assertFileDoesNotExist("{$this->path}/resources/views/admin/categories/index.blade.php"); + $this->assertFileDoesNotExist("{$this->path}/tests/Feature/Admin/CategoriesControllerTest.php"); + $this->assertFileDoesNotExist("{$this->path}/routes/admin.php"); + } + + #[Test] + public function it_generates_inertia_pages_with_the_views_option(): void + { + $this->artisan('generate:views', ['table' => 'categories', '--create' => true, '--views' => 'inertia'])->assertSuccessful(); + + $this->assertFileExists("{$this->path}/resources/js/pages/admin/categories/Index.tsx"); + $this->assertFileExists("{$this->path}/resources/js/pages/admin/categories/types.ts"); + $this->assertFileExists("{$this->path}/app/Http/Resources/Admin/CategoryResource.php"); + } + + #[Test] + public function it_chooses_the_fields_with_options(): void + { + $this->artisan('generate:form', ['table' => 'products', '--form' => 'name,price,tags']) + ->expectsOutputToContain("TextInput::make('name')") + ->expectsOutputToContain("Select::make('tags')") + ->doesntExpectOutputToContain("TextInput::make('stock')") + ->assertSuccessful(); + } + + #[Test] + public function it_asks_for_the_fields_when_interactive(): void + { + $this->artisan('generate:form', ['table' => 'categories', '--interactive' => true]) + ->expectsQuestion('Which fields go in the index table?', ['name']) + ->expectsQuestion('Which fields go in the form?', ['name']) + ->expectsQuestion('Which fields go on the show page?', ['name', 'slug']) + ->expectsQuestion('Media collections (leave empty for none)', '') + ->expectsQuestion('Which views?', 'blade') + ->expectsOutputToContain("TextInput::make('name')") + ->doesntExpectOutputToContain("TextInput::make('slug')") + ->assertSuccessful(); + } + + #[Test] + public function it_writes_a_user_type_migration_when_the_table_does_not_exist(): void + { + $this->artisan('generate:auth', ['table' => 'vendors', '--migration' => true, '--create' => true]) + ->expectsOutputToContain('php artisan migrate') + ->assertSuccessful(); + + $migrations = glob("{$this->path}/database/migrations/*_create_vendors_table.php"); + + $this->assertCount(1, $migrations); + $this->assertStringContainsString('UserSchema::columns($table);', file_get_contents($migrations[0])); + } + + #[Test] + public function it_generates_a_user_type(): void + { + (new Filesystem)->ensureDirectoryExists("{$this->path}/config"); + copy(dirname(__DIR__, 3) . '/vendor/orchestra/testbench-core/laravel/config/auth.php', "{$this->path}/config/auth.php"); + $this->app->useConfigPath("{$this->path}/config"); + + $this->artisan('generate:auth', ['table' => 'public_users', '--type' => 'member', '--guard' => 'web_member', '--create' => true]) + ->assertSuccessful(); + + $model = file_get_contents("{$this->path}/app/Models/PublicUser.php"); + + $this->assertStringContainsString('class PublicUser extends User', $model); + $this->assertStringContainsString('use Javaabu\\Auth\\Models\\User;', $model); + $this->assertStringNotContainsString('use SoftDeletes;', $model); + + $config = file_get_contents("{$this->path}/config/auth.php"); + + $this->assertStringContainsString("'web_member' => [\n 'driver' => 'session',\n 'provider' => 'public_users',", $config); + $this->assertStringContainsString("'model' => PublicUser::class,", $config); + $this->assertStringContainsString("'members' => [\n 'provider' => 'public_users',", $config); + + $provider = file_get_contents("{$this->path}/app/Providers/AppServiceProvider.php"); + + $this->assertStringContainsString("UserTypes::register('member', [", $provider); + $this->assertStringContainsString("'guard' => 'web_member',", $provider); + + $this->assertStringContainsString("Route::authRoutes('member', ['prefix' => 'member']);", file_get_contents("{$this->path}/routes/web.php")); + $this->assertStringContainsString("Route::userAccountActions('member', ['prefix' => 'public-users']);", file_get_contents("{$this->path}/routes/admin.php")); + + $form = file_get_contents("{$this->path}/app/Stencil/PublicUserForm.php"); + + $this->assertStringContainsString('->password()', $form); + $this->assertStringContainsString('->options(UserStatuses::class)', $form); + $this->assertStringNotContainsString("'remember_token'", $form); + $this->assertStringNotContainsString("'login_attempts'", $form); + + // running it again changes nothing + $this->artisan('generate:auth', ['table' => 'public_users', '--type' => 'member', '--guard' => 'web_member', '--create' => true])->assertSuccessful(); + + $this->assertEquals(1, substr_count(file_get_contents("{$this->path}/config/auth.php"), "'web_member' =>")); + $this->assertEquals(1, substr_count(file_get_contents("{$this->path}/app/Providers/AppServiceProvider.php"), "UserTypes::register('member'")); + } + + #[Test] + public function it_gives_an_admin_user_type_roles(): void + { + config()->set('generators.admin.user_model', 'App\\Models\\PublicUser'); + + $this->artisan('generate:auth', ['table' => 'public_users', '--type' => 'admin', '--except' => 'views,test,config,routes']) + ->expectsOutputToContain('use HasRoles;') + ->assertSuccessful(); + } + + #[Test] + public function it_prints_a_user_type_without_create(): void + { + $this->artisan('generate:auth', ['table' => 'customers', '--except' => 'views,test']) + ->expectsOutputToContain("UserTypes::register('customer', [") + ->expectsOutputToContain("Route::authRoutes('customer', ['prefix' => 'customer']);") + ->assertSuccessful(); + } + + #[Test] + public function it_explains_pivot_tables(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('product_tag is the pivot table of products and tags'); + + $this->artisan('generate:all', ['table' => 'product_tag']); + } + + #[Test] + public function it_speaks_dhivehi(): void + { + app()->setLocale('dv'); + + $this->expectException(TableDoesNotExistException::class); + $this->expectExceptionMessage('"missing" ޓޭބަލެއް ނުފެނުނު.'); + + $this->artisan('generate:model', ['table' => 'missing']); + } + + #[Test] + public function it_writes_the_admin_guard_into_the_generated_code(): void + { + config()->set('generators.admin.guard', 'web_admin'); + config()->set('generators.admin.user_model', 'App\\Models\\Admin'); + + $this->artisan('generate:all', ['table' => 'categories', '--create' => true])->assertSuccessful(); + + $this->assertStringContainsString("protected ?string \$guard = 'web_admin';", file_get_contents("{$this->path}/app/Policies/CategoryPolicy.php")); + $this->assertStringContainsString("Permissions::resource('categories', label: 'Categories')->guards(['web_admin']);", file_get_contents("{$this->path}/app/Providers/AppServiceProvider.php")); + $this->assertStringContainsString("Route::bulkActions('categories', CategoryBulkActions::class, ['guard' => 'web_admin']);", file_get_contents("{$this->path}/routes/admin.php")); + + $test = file_get_contents("{$this->path}/tests/Feature/Admin/CategoriesControllerTest.php"); + + $this->assertStringContainsString('use App\\Models\\Admin;', $test); + $this->assertStringContainsString("\$this->actingAs(\$user, 'web_admin');", $test); + } + + #[Test] + public function it_rejects_several_tables(): void + { + $this->expectException(MultipleTablesSuppliedException::class); + + $this->artisan('generate:model', ['table' => 'products,categories']); + } + + #[Test] + public function it_rejects_a_missing_table(): void + { + $this->expectException(TableDoesNotExistException::class); + + $this->artisan('generate:model', ['table' => 'missing']); + } + + #[Test] + public function it_rejects_missing_columns(): void + { + $this->expectException(ColumnDoesNotExistException::class); + + $this->artisan('generate:model', ['table' => 'products', '--columns' => 'name,missing']); + } + + #[Test] + public function it_rejects_unknown_fields(): void + { + $this->expectException(ColumnDoesNotExistException::class); + + $this->artisan('generate:form', ['table' => 'products', '--form' => 'name,missing']); + } +} diff --git a/tests/Feature/Commands/GenerateControllerCommandTest.php b/tests/Feature/Commands/GenerateControllerCommandTest.php deleted file mode 100644 index 158691b..0000000 --- a/tests/Feature/Commands/GenerateControllerCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('Controllers/CategoriesController.php'); - - $this->artisan('generate:controller', ['table' => 'categories']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_controller_file(): void - { - $expected_path = $this->app->path('Http/Controllers/Admin/CategoriesController.php'); - $expected_content = $this->getTestStubContents('Controllers/CategoriesController.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:controller', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/GenerateExportCommandTest.php b/tests/Feature/Commands/GenerateExportCommandTest.php deleted file mode 100644 index 61c2598..0000000 --- a/tests/Feature/Commands/GenerateExportCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('Exports/CategoriesExport.php'); - - $this->artisan('generate:export', ['table' => 'categories']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_export_file(): void - { - $expected_path = $this->app->path('Exports/CategoriesExport.php'); - $expected_content = $this->getTestStubContents('Exports/CategoriesExport.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:export', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/GenerateFactoryCommandTest.php b/tests/Feature/Commands/GenerateFactoryCommandTest.php deleted file mode 100644 index 2861aa4..0000000 --- a/tests/Feature/Commands/GenerateFactoryCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('factories/CategoryFactory.php'); - - $this->artisan('generate:factory', ['table' => 'categories']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_factory_file(): void - { - $expected_path = $this->app->databasePath('factories/CategoryFactory.php'); - $expected_content = $this->getTestStubContents('factories/CategoryFactory.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:factory', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/GenerateModelCommandTest.php b/tests/Feature/Commands/GenerateModelCommandTest.php deleted file mode 100644 index eb51ee6..0000000 --- a/tests/Feature/Commands/GenerateModelCommandTest.php +++ /dev/null @@ -1,93 +0,0 @@ -deleteFile($this->app->path('Models/Product.php')); - $this->deleteFile($this->app->path('Models/Category.php')); - - // setup skeleton service provider - $this->copyFile( - $this->getTestStubPath('Providers/SkeletonAppServiceProvider.php'), - $this->app->path('Providers/AppServiceProvider.php') - ); - } - - protected function tearDown(): void - { - $this->deleteFile($this->app->path('Models/Product.php')); - $this->deleteFile($this->app->path('Models/Category.php')); - - // setup standard service provider - $this->copyFile( - $this->getTestStubPath('Providers/AppServiceProvider.php'), - $this->app->path('Providers/AppServiceProvider.php') - ); - - parent::tearDown(); - } - - public function test_it_can_generate_model_output(): void - { - $expected_content = $this->getTestStubContents('Models/Product.php'); - - $this->artisan('generate:model', ['table' => 'products']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_a_new_model_file(): void - { - $expected_path = $this->app->path('Models/Product.php'); - $expected_content = $this->getTestStubContents('Models/Product.php'); - - $this->artisan('generate:model', ['table' => 'products', '--create' => true]) - ->assertSuccessful(); - - $this->assertFileExists($expected_path); - - $actual_content = $this->getGeneratedFileContents($expected_path); - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_adds_subject_types_and_morph_maps(): void - { - $expected_path = $this->app->path('Models/Category.php'); - $expected_content = $this->getTestStubContents('Models/Category.php'); - - $this->artisan('generate:model', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - - $this->assertFileExists($expected_path); - - $actual_content = $this->getGeneratedFileContents($expected_path); - $this->assertEquals($expected_content, $actual_content); - - $expected_path = $this->app->path('Models/Product.php'); - $expected_content = $this->getTestStubContents('Models/Product.php'); - - $this->artisan('generate:model', ['table' => 'products', '--create' => true]) - ->assertSuccessful(); - - $this->assertFileExists($expected_path); - - $actual_content = $this->getGeneratedFileContents($expected_path); - $this->assertEquals($expected_content, $actual_content); - - $expected_path = $this->app->path('Providers/AppServiceProvider.php'); - $expected_content = $this->getTestStubContents('Providers/ModelsAppServiceProvider.php'); - $actual_content = $this->getGeneratedFileContents($expected_path); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Feature/Commands/GeneratePermissionsCommandTest.php b/tests/Feature/Commands/GeneratePermissionsCommandTest.php deleted file mode 100644 index b716750..0000000 --- a/tests/Feature/Commands/GeneratePermissionsCommandTest.php +++ /dev/null @@ -1,93 +0,0 @@ -deleteFile($this->app->databasePath('seeders/PermissionsSeeder.php')); - } - - protected function tearDown(): void - { - $this->deleteFile($this->app->databasePath('seeders/PermissionsSeeder.php')); - - parent::tearDown(); - } - - public function test_it_can_generate_permissions_output(): void - { - $expected_content = $this->getTestStubContents('seeders/_permissions.stub'); - - $this->artisan('generate:permissions', ['table' => 'categories']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_a_new_permissions_file(): void - { - $expected_path = $this->app->databasePath('seeders/PermissionsSeeder.php'); - $expected_content = $this->getTestStubContents('seeders/PermissionsSeederNew.php'); - - $this->artisan('generate:permissions', ['table' => 'products', '--create' => true]) - ->assertSuccessful(); - - $this->artisan('generate:permissions', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - - $this->assertFileExists($expected_path); - - $actual_content = $this->getGeneratedFileContents($expected_path); - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_append_permissions_to_an_existing_permissions_seeder(): void - { - $expected_path = $this->app->databasePath('seeders/PermissionsSeeder.php'); - - $this->copyFile($this->getTestStubPath('seeders/PermissionsSeeder.php'), $expected_path); - $expected_content = $this->getTestStubContents('seeders/PermissionsSeederExisting.php'); - - $this->artisan('generate:permissions', ['table' => 'products', '--create' => true]) - ->assertSuccessful(); - - $this->artisan('generate:permissions', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - - $this->assertFileExists($expected_path); - - $actual_content = $this->getGeneratedFileContents($expected_path); - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_does_not_duplicate_permissions_when_the_generate_command_is_called_multiple_times(): void - { - $expected_path = $this->app->databasePath('seeders/PermissionsSeeder.php'); - - $this->copyFile($this->getTestStubPath('seeders/PermissionsSeeder.php'), $expected_path); - $expected_content = $this->getTestStubContents('seeders/PermissionsSeederExisting.php'); - - $this->artisan('generate:permissions', ['table' => 'products', '--create' => true]) - ->assertSuccessful(); - - $this->artisan('generate:permissions', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - - $this->artisan('generate:permissions', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - - $this->assertFileExists($expected_path); - - $actual_content = $this->getGeneratedFileContents($expected_path); - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Feature/Commands/GeneratePolicyCommandTest.php b/tests/Feature/Commands/GeneratePolicyCommandTest.php deleted file mode 100644 index 9b24808..0000000 --- a/tests/Feature/Commands/GeneratePolicyCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('Policies/CategoryPolicy.php'); - - $this->artisan('generate:policy', ['table' => 'categories']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_policy_file(): void - { - $expected_path = $this->app->path('Policies/CategoryPolicy.php'); - $expected_content = $this->getTestStubContents('Policies/CategoryPolicy.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:policy', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/GenerateRequestCommandTest.php b/tests/Feature/Commands/GenerateRequestCommandTest.php deleted file mode 100644 index 72f0d56..0000000 --- a/tests/Feature/Commands/GenerateRequestCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('Requests/CategoriesRequest.php'); - - $this->artisan('generate:request', ['table' => 'categories']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_request_file(): void - { - $expected_path = $this->app->path('Http/Requests/CategoriesRequest.php'); - $expected_content = $this->getTestStubContents('Requests/CategoriesRequest.php'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:request', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/GenerateRoutesCommandTest.php b/tests/Feature/Commands/GenerateRoutesCommandTest.php deleted file mode 100644 index aef1e6c..0000000 --- a/tests/Feature/Commands/GenerateRoutesCommandTest.php +++ /dev/null @@ -1,55 +0,0 @@ -copyFile( - $this->getTestStubPath('routes/skeletonAdmin.php'), - $this->app->basePath('routes/admin.php') - ); - } - - protected function tearDown(): void - { - $this->deleteFile($this->app->path('routes/admin.php')); - - parent::tearDown(); - } - - public function test_it_can_generate_routes_output(): void - { - $expected_content = $this->getTestStubContents('routes/_admin.stub'); - - $this->artisan('generate:routes', ['table' => 'categories']) - ->expectsOutput($expected_content); - } - - - public function test_it_can_append_routes_to_an_existing_routes_file(): void - { - $expected_path = $this->app->basePath('routes/admin.php'); - $expected_content = $this->getTestStubContents('routes/modelsAdmin.php'); - - $this->artisan('generate:routes', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - - $this->artisan('generate:routes', ['table' => 'products', '--create' => true]) - ->assertSuccessful(); - - $this->assertFileExists($expected_path); - - $actual_content = $this->getGeneratedFileContents($expected_path); - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Feature/Commands/GenerateTestCommandTest.php b/tests/Feature/Commands/GenerateTestCommandTest.php deleted file mode 100644 index c15a099..0000000 --- a/tests/Feature/Commands/GenerateTestCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -getTestStubContents('tests/CategoriesControllerTest.stub'); - - $this->artisan('generate:test', ['table' => 'categories']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_test_file(): void - { - $expected_path = $this->app->basePath('tests/Feature/Controllers/Admin/CategoriesControllerTest.php'); - $expected_content = $this->getTestStubContents('tests/CategoriesControllerTest.stub'); - - $this->partialMock(Filesystem::class, function (MockInterface $mock) use ($expected_path, $expected_content) { - $mock->shouldReceive('makeDirectory') - ->andReturnTrue(); - - $mock->shouldReceive('put') - ->once() - ->with($expected_path, $expected_content) - ->andReturnTrue(); - }); - - $this->artisan('generate:test', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - } -} diff --git a/tests/Feature/Commands/GenerateViewsCommandTest.php b/tests/Feature/Commands/GenerateViewsCommandTest.php deleted file mode 100644 index 8bfd5d3..0000000 --- a/tests/Feature/Commands/GenerateViewsCommandTest.php +++ /dev/null @@ -1,136 +0,0 @@ -deleteDirectory($this->app->resourcePath('views/admin')); - - // setup skeleton menu - $this->makeDirectory($this->app->path('Menus/AdminSidebar.php')); - - $this->copyFile( - $this->getTestStubPath('Menus/AdminSidebar.php'), - $this->app->path('Menus/AdminSidebar.php') - ); - } - - protected function tearDown(): void - { - // delete all views - $this->deleteDirectory($this->app->resourcePath('views/admin')); - - // delete menus - $this->deleteDirectory($this->app->path('Menus')); - - parent::tearDown(); - } - - public function test_it_can_generate_views_output(): void - { - $expected_content = ''; - - $expected_content .= "// products.blade.php\n"; - $expected_content .= $this->getTestStubContents('views/products/products.blade.php'); - - $expected_content .= "// _actions.blade.php\n"; - $expected_content .= $this->getTestStubContents('views/products/_actions.blade.php'); - - $expected_content .= "// _form.blade.php\n"; - $expected_content .= $this->getTestStubContents('views/products/_form.blade.php'); - - $expected_content .= "// create.blade.php\n"; - $expected_content .= $this->getTestStubContents('views/products/create.blade.php'); - - $expected_content .= "// edit.blade.php\n"; - $expected_content .= $this->getTestStubContents('views/products/edit.blade.php'); - - $expected_content .= "// _details.blade.php\n"; - $expected_content .= $this->getTestStubContents('views/products/_details.blade.php'); - - $expected_content .= "// show.blade.php\n"; - $expected_content .= $this->getTestStubContents('views/products/show.blade.php'); - - $expected_content .= "// _bulk.blade.php\n"; - $expected_content .= $this->getTestStubContents('views/products/_bulk.blade.php'); - - $expected_content .= "// _list.blade.php\n"; - $expected_content .= $this->getTestStubContents('views/products/_list.blade.php'); - - $expected_content .= "// _table.blade.php\n"; - $expected_content .= $this->getTestStubContents('views/products/_table.blade.php'); - - $expected_content .= "// _filter.blade.php\n"; - $expected_content .= $this->getTestStubContents('views/products/_filter.blade.php'); - - $expected_content .= "// index.blade.php\n"; - $expected_content .= $this->getTestStubContents('views/products/index.blade.php'); - - $this->artisan('generate:views', ['table' => 'products']) - ->expectsOutput($expected_content); - } - - public function test_it_can_generate_new_view_files(): void - { - $views = [ - 'products.blade.php', - '_actions.blade.php', - '_form.blade.php', - 'create.blade.php', - 'edit.blade.php', - '_details.blade.php', - 'show.blade.php', - '_bulk.blade.php', - '_list.blade.php', - '_table.blade.php', - 'index.blade.php', - ]; - - $content = []; - - // load expected path and content - foreach ($views as $view) { - $content[$view] = [ - 'expected_path' => $this->app->resourcePath('views/admin/products/' . $view), - 'expected_content' => $this->getTestStubContents('views/products/' . $view), - ]; - } - - $this->artisan('generate:views', ['table' => 'products', '--create' => true]) - ->assertSuccessful(); - - foreach ($content as $view => $test_data) { - $this->assertFileExists($test_data['expected_path'], "$view not generated"); - - $actual_content = $this->getGeneratedFileContents($test_data['expected_path']); - $this->assertEquals($test_data['expected_content'], $actual_content, "$view content wrong"); - } - } - - public function test_it_adds_sidebar_links(): void - { - $expected_path = $this->app->path('Menus/AdminSidebar.php'); - $expected_content = $this->getTestStubContents('Menus/ModelsAdminSidebar.php'); - - $this->artisan('generate:views', ['table' => 'categories', '--create' => true]) - ->assertSuccessful(); - - $this->artisan('generate:views', ['table' => 'products', '--create' => true]) - ->assertSuccessful(); - - $this->assertFileExists($expected_path); - - $actual_content = $this->getGeneratedFileContents($expected_path); - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Feature/GeneratorSnapshotsTest.php b/tests/Feature/GeneratorSnapshotsTest.php new file mode 100644 index 0000000..2f102ad --- /dev/null +++ b/tests/Feature/GeneratorSnapshotsTest.php @@ -0,0 +1,92 @@ +, 2: array>}> + */ + public static function resources(): array + { + $all = [ + ModelGenerator::class, + FactoryGenerator::class, + PolicyGenerator::class, + PermissionsGenerator::class, + FiltersGenerator::class, + BulkActionsGenerator::class, + FormGenerator::class, + ControllerGenerator::class, + RoutesGenerator::class, + TestGenerator::class, + ]; + + return [ + 'products, blade' => ['products', [], [...$all, BladeViewsGenerator::class, ExportGenerator::class]], + 'products, inertia' => ['products', ['views' => 'inertia'], [ControllerGenerator::class, ResourceGenerator::class, InertiaPagesGenerator::class, TestGenerator::class]], + 'categories, blade' => ['categories', [], [ModelGenerator::class, FiltersGenerator::class, BladeViewsGenerator::class]], + 'orders, enum class and a key to a slug' => ['orders', [], [ModelGenerator::class, FactoryGenerator::class, FormGenerator::class, TestGenerator::class]], + 'products nested in categories, blade' => ['products', ['parent' => 'categories', 'index' => 'name,price', 'form' => 'name,price,status,tags'], [ControllerGenerator::class, BulkActionsGenerator::class, RoutesGenerator::class, BladeViewsGenerator::class, TestGenerator::class]], + 'products nested in categories, inertia' => ['products', ['parent' => 'category_id', 'views' => 'inertia', 'index' => 'name,price', 'form' => 'name,price'], [ControllerGenerator::class, ResourceGenerator::class, InertiaPagesGenerator::class, TestGenerator::class]], + 'posts with media, blade' => ['posts', ['media' => 'featured_image:image,gallery:image:multiple,attachments:document:multiple'], [ModelGenerator::class, FormGenerator::class, BladeViewsGenerator::class]], + 'posts with media, inertia' => ['posts', ['media' => 'featured_image,attachments:document:multiple', 'views' => 'inertia'], [ResourceGenerator::class, InertiaPagesGenerator::class]], + 'tags, a pivot on the other side' => ['tags', [], [ModelGenerator::class, FormGenerator::class]], + ]; + } + + /** + * @param array $options + * @param array> $generators + */ + #[Test] + #[DataProvider('resources')] + public function it_generates_the_code_in_the_snapshots(string $table, array $options, array $generators): void + { + if (config('database.default') !== 'sqlite') { + $this->markTestSkipped('The snapshots are of the SQLite schema; other drivers keep string lengths and comments.'); + } + + $resource = app(ResourceResolver::class)->resolve($table, ResourceOptions::fromArray($options)); + + $output = []; + + foreach ($generators as $class) { + foreach ((new $class($resource))->files() as $file) { + $output[] = "// {$file->relativePath()}\n{$file->contents}"; + } + } + + $name = str_replace([', ', ' '], ['.', '-'], $this->dataName()); + + $this->assertMatchesSnapshot("{$name}.txt", implode("\n", $output)); + } +} diff --git a/tests/Feature/TranslatableTest.php b/tests/Feature/TranslatableTest.php new file mode 100644 index 0000000..ad68fe5 --- /dev/null +++ b/tests/Feature/TranslatableTest.php @@ -0,0 +1,107 @@ +resolve($table, ResourceOptions::fromArray($options)); + } + + #[Test] + public function it_makes_the_text_columns_translatable_when_the_package_is_installed(): void + { + Translations::fake(true); + + $resource = $this->resolve('pages'); + + // unique columns such as slugs aren't translated + $this->assertEquals(['title', 'body'], $resource->translatable); + + $model = (new ModelGenerator($resource))->renderModel(); + + $this->assertStringContainsString('class Page extends Model implements JsonTranslatable', $model); + $this->assertStringContainsString('use IsJsonTranslatable;', $model); + $this->assertStringContainsString("protected array \$translatable = ['title', 'body'];", $model); + + $form = (new FormGenerator($resource))->renderForm(); + + $this->assertStringContainsString("TextInput::make('title')\n ->label(__('Title'))\n ->required()\n ->maxLength(255)\n ->translatable(),", $form); + + $test = (new TestGenerator($resource))->renderTest(); + + $this->assertStringContainsString('use InteractsWithLanguages;', $test); + $this->assertStringContainsString("'title_dv' => 'ދިވެހި title',", $test); + } + + #[Test] + public function it_writes_plain_models_without_the_package(): void + { + Translations::fake(false); + + $resource = $this->resolve('pages'); + + $this->assertEquals([], $resource->translatable); + $this->assertArrayNotHasKey('translations', $resource->fields); + $this->assertArrayNotHasKey('lang', $resource->fields); + + $this->assertStringNotContainsString('Translatable', (new ModelGenerator($resource))->renderModel()); + $this->assertStringNotContainsString('translatable()', (new FormGenerator($resource))->renderForm()); + $this->assertStringNotContainsString('InteractsWithLanguages', (new TestGenerator($resource))->renderTest()); + } + + #[Test] + public function it_skips_tables_without_the_translation_columns(): void + { + Translations::fake(true); + + $this->assertEquals([], $this->resolve('posts')->translatable); + } + + #[Test] + public function it_takes_the_translatable_columns_from_the_option(): void + { + Translations::fake(true); + + $this->assertEquals(['title'], $this->resolve('pages', ['translatable' => 'title'])->translatable); + } + + #[Test] + public function it_can_be_turned_off_in_config(): void + { + Translations::fake(true); + + config()->set('generators.translatable', false); + + $this->assertEquals([], $this->resolve('pages')->translatable); + } + + #[Test] + public function it_explains_the_missing_columns(): void + { + Translations::fake(true); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("\$table->json('translations')->nullable();"); + + $this->resolve('posts', ['translatable' => 'title']); + } +} diff --git a/tests/TestSupport/Enums/OrderStatuses.php b/tests/Fixtures/Enums/OrderStatuses.php similarity index 82% rename from tests/TestSupport/Enums/OrderStatuses.php rename to tests/Fixtures/Enums/OrderStatuses.php index e11b58d..57053e8 100644 --- a/tests/TestSupport/Enums/OrderStatuses.php +++ b/tests/Fixtures/Enums/OrderStatuses.php @@ -1,6 +1,6 @@ timestamps(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_144111_create_products_table.php b/tests/Fixtures/migrations/2024_04_28_144111_create_products_table.php similarity index 99% rename from tests/TestSupport/database/2024_04_28_144111_create_products_table.php rename to tests/Fixtures/migrations/2024_04_28_144111_create_products_table.php index b37d2de..94062fd 100644 --- a/tests/TestSupport/database/2024_04_28_144111_create_products_table.php +++ b/tests/Fixtures/migrations/2024_04_28_144111_create_products_table.php @@ -34,6 +34,7 @@ public function up() $table->softDeletes(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_144112_create_orders_table.php b/tests/Fixtures/migrations/2024_04_28_144112_create_orders_table.php similarity index 85% rename from tests/TestSupport/database/2024_04_28_144112_create_orders_table.php rename to tests/Fixtures/migrations/2024_04_28_144112_create_orders_table.php index a0fbfd9..5ffa114 100644 --- a/tests/TestSupport/database/2024_04_28_144112_create_orders_table.php +++ b/tests/Fixtures/migrations/2024_04_28_144112_create_orders_table.php @@ -3,7 +3,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -use Javaabu\Generators\Tests\TestSupport\Enums\OrderStatuses; +use Javaabu\Generators\Tests\Fixtures\Enums\OrderStatuses; return new class extends Migration { @@ -19,7 +19,7 @@ public function up() $table->string('order_no', 4); $table->foreignId('category_id')->constrained()->cascadeOnDelete(); $table->string('product_slug'); - $table->nativeEnum('status', OrderStatuses::class)->index(); + $table->string('status')->comment('enum:' . OrderStatuses::class)->index(); $table->foreign('product_slug') ->references('slug') @@ -29,6 +29,7 @@ public function up() $table->timestamps(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_144113_create_countries_table.php b/tests/Fixtures/migrations/2024_04_28_144113_create_countries_table.php similarity index 99% rename from tests/TestSupport/database/2024_04_28_144113_create_countries_table.php rename to tests/Fixtures/migrations/2024_04_28_144113_create_countries_table.php index 3db4a8f..bc287c5 100644 --- a/tests/TestSupport/database/2024_04_28_144113_create_countries_table.php +++ b/tests/Fixtures/migrations/2024_04_28_144113_create_countries_table.php @@ -20,6 +20,7 @@ public function up() $table->timestamps(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_144113_create_posts_table.php b/tests/Fixtures/migrations/2024_04_28_144113_create_posts_table.php similarity index 99% rename from tests/TestSupport/database/2024_04_28_144113_create_posts_table.php rename to tests/Fixtures/migrations/2024_04_28_144113_create_posts_table.php index c5efaa4..4c326ab 100644 --- a/tests/TestSupport/database/2024_04_28_144113_create_posts_table.php +++ b/tests/Fixtures/migrations/2024_04_28_144113_create_posts_table.php @@ -24,6 +24,7 @@ public function up() $table->timestamps(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_144114_create_payments_table.php b/tests/Fixtures/migrations/2024_04_28_144114_create_payments_table.php similarity index 99% rename from tests/TestSupport/database/2024_04_28_144114_create_payments_table.php rename to tests/Fixtures/migrations/2024_04_28_144114_create_payments_table.php index 3c25c9b..0967bb6 100644 --- a/tests/TestSupport/database/2024_04_28_144114_create_payments_table.php +++ b/tests/Fixtures/migrations/2024_04_28_144114_create_payments_table.php @@ -21,6 +21,7 @@ public function up() $table->softDeletes(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_144115_create_certificates_table.php b/tests/Fixtures/migrations/2024_04_28_144115_create_certificates_table.php similarity index 99% rename from tests/TestSupport/database/2024_04_28_144115_create_certificates_table.php rename to tests/Fixtures/migrations/2024_04_28_144115_create_certificates_table.php index ee88e7d..bfbfb6e 100644 --- a/tests/TestSupport/database/2024_04_28_144115_create_certificates_table.php +++ b/tests/Fixtures/migrations/2024_04_28_144115_create_certificates_table.php @@ -22,6 +22,7 @@ public function up() $table->softDeletes(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_144116_create_all_foreigns_table.php b/tests/Fixtures/migrations/2024_04_28_144116_create_all_foreigns_table.php similarity index 99% rename from tests/TestSupport/database/2024_04_28_144116_create_all_foreigns_table.php rename to tests/Fixtures/migrations/2024_04_28_144116_create_all_foreigns_table.php index 3892935..2e49f68 100644 --- a/tests/TestSupport/database/2024_04_28_144116_create_all_foreigns_table.php +++ b/tests/Fixtures/migrations/2024_04_28_144116_create_all_foreigns_table.php @@ -26,6 +26,7 @@ public function up() $table->timestamps(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_144117_create_customers_table.php b/tests/Fixtures/migrations/2024_04_28_144117_create_customers_table.php similarity index 99% rename from tests/TestSupport/database/2024_04_28_144117_create_customers_table.php rename to tests/Fixtures/migrations/2024_04_28_144117_create_customers_table.php index e978569..96b4e72 100644 --- a/tests/TestSupport/database/2024_04_28_144117_create_customers_table.php +++ b/tests/Fixtures/migrations/2024_04_28_144117_create_customers_table.php @@ -35,6 +35,7 @@ public function up() $table->foreignId('category_id')->nullable()->constrained()->nullOnDelete(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_144118_create_public_users_table.php b/tests/Fixtures/migrations/2024_04_28_144118_create_public_users_table.php similarity index 99% rename from tests/TestSupport/database/2024_04_28_144118_create_public_users_table.php rename to tests/Fixtures/migrations/2024_04_28_144118_create_public_users_table.php index 293d1f4..f3108b2 100644 --- a/tests/TestSupport/database/2024_04_28_144118_create_public_users_table.php +++ b/tests/Fixtures/migrations/2024_04_28_144118_create_public_users_table.php @@ -29,6 +29,7 @@ public function up() $table->softDeletes(); }); } + /** * Reverse the migrations. * diff --git a/tests/Fixtures/migrations/2024_04_28_144119_create_tags_table.php b/tests/Fixtures/migrations/2024_04_28_144119_create_tags_table.php new file mode 100644 index 0000000..9837a60 --- /dev/null +++ b/tests/Fixtures/migrations/2024_04_28_144119_create_tags_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('product_tag', function (Blueprint $table) { + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('tag_id')->constrained()->cascadeOnDelete(); + $table->primary(['product_id', 'tag_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_tag'); + Schema::dropIfExists('tags'); + } +}; diff --git a/tests/Fixtures/migrations/2024_04_28_144120_create_pages_table.php b/tests/Fixtures/migrations/2024_04_28_144120_create_pages_table.php new file mode 100644 index 0000000..a0ea094 --- /dev/null +++ b/tests/Fixtures/migrations/2024_04_28_144120_create_pages_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('title'); + $table->string('slug')->unique(); + $table->text('body')->nullable(); + $table->json('translations')->nullable(); + $table->string('lang')->nullable()->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('pages'); + } +}; diff --git a/tests/TestSupport/database/2025_03_27_212600_create_single_value_enum_orders_table.php b/tests/Fixtures/migrations/2025_03_27_212600_create_single_value_enum_orders_table.php similarity index 84% rename from tests/TestSupport/database/2025_03_27_212600_create_single_value_enum_orders_table.php rename to tests/Fixtures/migrations/2025_03_27_212600_create_single_value_enum_orders_table.php index 06515ac..2b339c1 100644 --- a/tests/TestSupport/database/2025_03_27_212600_create_single_value_enum_orders_table.php +++ b/tests/Fixtures/migrations/2025_03_27_212600_create_single_value_enum_orders_table.php @@ -3,7 +3,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -use Javaabu\Generators\Tests\TestSupport\Enums\SingleCaseOrderStatuses; +use Javaabu\Generators\Tests\Fixtures\Enums\SingleCaseOrderStatuses; return new class extends Migration { @@ -19,7 +19,7 @@ public function up() $table->string('order_no', 4); $table->foreignId('category_id')->constrained()->cascadeOnDelete(); $table->string('product_slug'); - $table->nativeEnum('status', SingleCaseOrderStatuses::class)->index(); + $table->string('status')->comment('enum:' . SingleCaseOrderStatuses::class)->index(); $table->foreign('product_slug') ->references('slug') @@ -29,6 +29,7 @@ public function up() $table->timestamps(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestCase.php b/tests/TestCase.php index 77e564e..9e2678a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,48 +2,90 @@ namespace Javaabu\Generators\Tests; -use Javaabu\GeneratorHelpers\Testing\InteractsWithTestFiles; -use Javaabu\GeneratorHelpers\Testing\InteractsWithTestStubs; +use Illuminate\Foundation\Testing\RefreshDatabase; use Javaabu\Generators\GeneratorsServiceProvider; -use Javaabu\Generators\Tests\TestSupport\Providers\TestServiceProvider; -use Javaabu\Schema\SchemaServiceProvider; +use Javaabu\Generators\Tests\Fixtures\Enums\OrderStatuses; +use Javaabu\Generators\Tests\Fixtures\Enums\SingleCaseOrderStatuses; use Orchestra\Testbench\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { - use InteractsWithTestStubs; - use InteractsWithTestFiles; + use RefreshDatabase; - protected function setUp(): void + protected function getPackageProviders($app): array { - parent::setUp(); - - $this->app['config']->set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); + return [ + GeneratorsServiceProvider::class, + ]; + } - $this->app['config']->set('session.serialization', 'php'); + protected function defineEnvironment($app): void + { + $app['config']->set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); - $this->app['config']->set('database.default', 'mysql'); + $driver = env('DB_CONNECTION', 'sqlite'); - $this->app['config']->set('database.connections.mysql', [ - 'driver' => 'mysql', - 'database' => env('DB_DATABASE'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'username' => env('DB_USERNAME'), - 'password' => env('DB_PASSWORD', ''), - 'prefix' => '', - ]); + $connections = [ + 'sqlite' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ], + 'mysql' => [ + 'driver' => 'mysql', + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'generators'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + ], + 'mariadb' => [ + 'driver' => 'mariadb', + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'generators'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + ], + 'pgsql' => [ + 'driver' => 'pgsql', + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'generators'), + 'username' => env('DB_USERNAME', 'postgres'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'search_path' => 'public', + ], + ]; - $this->loadTestStubsFrom(__DIR__.'/stubs'); + $app['config']->set('database.default', $driver); + $app['config']->set("database.connections.{$driver}", $connections[$driver]); + // SQLite keeps no column comments, so name the enum classes in config + $app['config']->set('generators.enums', [ + 'orders.status' => OrderStatuses::class, + 'single_value_enum_orders.status' => SingleCaseOrderStatuses::class, + ]); } - protected function getPackageProviders($app) + /** + * @return array + */ + protected function migrateFreshUsing(): array { return [ - SchemaServiceProvider::class, - GeneratorsServiceProvider::class, - TestServiceProvider::class + '--path' => __DIR__ . '/Fixtures/migrations', + '--realpath' => true, + '--drop-views' => true, ]; } } diff --git a/tests/TestSupport/Providers/TestServiceProvider.php b/tests/TestSupport/Providers/TestServiceProvider.php deleted file mode 100644 index 2a1ff93..0000000 --- a/tests/TestSupport/Providers/TestServiceProvider.php +++ /dev/null @@ -1,24 +0,0 @@ -loadMigrationsFrom(__DIR__ . '/../database'); - } - - /** - * Register the application services. - */ - public function register() - { - - } -} diff --git a/tests/Unit/BooleanFieldTest.php b/tests/Unit/BooleanFieldTest.php deleted file mode 100644 index 4fe73f5..0000000 --- a/tests/Unit/BooleanFieldTest.php +++ /dev/null @@ -1,19 +0,0 @@ -assertEquals('on_sale = $request->input(\'on_sale\', false)', (new BooleanField('on_sale'))->renderAssignment()); - } - - public function test_it_can_generate_boolean_assignment_from_array(): void - { - $this->assertEquals('on_sale = $input[\'on_sale\'] ?? false', (new BooleanField('on_sale'))->renderAssignment('$input[', ']')); - } -} diff --git a/tests/Unit/DecimalFieldTest.php b/tests/Unit/DecimalFieldTest.php deleted file mode 100644 index 303b920..0000000 --- a/tests/Unit/DecimalFieldTest.php +++ /dev/null @@ -1,17 +0,0 @@ -assertEquals('0.01', (new DecimalField('price', 14, 2))->getStep()); - $this->assertEquals('1', (new DecimalField('price', 14, 0))->getStep()); - $this->assertEquals('0.001', (new DecimalField('price', 14, 3))->getStep()); - $this->assertEquals('0.1', (new DecimalField('price', 14, 1))->getStep()); - } -} diff --git a/tests/Unit/DocsTest.php b/tests/Unit/DocsTest.php new file mode 100644 index 0000000..8e1b814 --- /dev/null +++ b/tests/Unit/DocsTest.php @@ -0,0 +1,50 @@ +root() . '/llms.txt'); + + preg_match_all('/\]\((docs\/[^)]+)\)/', $llms, $matches); + + foreach ($matches[1] as $link) { + $this->assertFileExists($this->root() . '/' . $link, "llms.txt links to a missing page: {$link}"); + } + + $skip = ['docs/about-us.md', 'docs/changelog.md', 'docs/contributing-to-docs.md']; + + foreach (glob($this->root() . '/docs/{,*/}*.md', GLOB_BRACE) as $page) { + $relative = substr($page, strlen($this->root()) + 1); + + if (in_array($relative, $skip, true)) { + continue; + } + + $this->assertContains($relative, $matches[1], "llms.txt doesn't link to {$relative}"); + } + } + + #[Test] + public function it_links_to_existing_pages_from_the_docs(): void + { + foreach (glob($this->root() . '/docs/{,*/}*.md', GLOB_BRACE) as $page) { + preg_match_all('/\]\(((?!https?:|#|\.\.\/\.\.\/)[^)#]+\.md)(#[^)]*)?\)/', file_get_contents($page), $matches); + + foreach ($matches[1] as $link) { + $this->assertFileExists(dirname($page) . '/' . $link, basename($page) . " links to a missing page: {$link}"); + } + } + } +} diff --git a/tests/Unit/FieldTest.php b/tests/Unit/FieldTest.php deleted file mode 100644 index 83fe5ac..0000000 --- a/tests/Unit/FieldTest.php +++ /dev/null @@ -1,37 +0,0 @@ - 255]; - } -} - -class FieldTest extends TestCase -{ - public function test_it_can_render_field_assignment(): void - { - $this->assertEquals('slug = $request->input(\'slug\')', (new FakeStringField('slug'))->renderAssignment()); - } - - public function test_it_can_render_field_attributes(): void - { - $this->assertEquals('maxlength="255" required :inline="false" show-placeholder', (new FakeStringField('slug'))->renderFormComponentAttributes()); - } - - public function test_it_can_render_a_component(): void - { - $this->assertEquals('', (new FakeStringField('slug'))->renderFormComponent()); - } -} diff --git a/tests/Unit/ForeignKeyFieldTest.php b/tests/Unit/ForeignKeyFieldTest.php deleted file mode 100644 index 65a5ca0..0000000 --- a/tests/Unit/ForeignKeyFieldTest.php +++ /dev/null @@ -1,55 +0,0 @@ -assertEquals('Category', (new ForeignKeyField('category_id', 'categories', 'id'))->getRelatedModelClass()); - $this->assertEquals('PostType', (new ForeignKeyField('post_type_id', 'post_types', 'id'))->getRelatedModelClass()); - $this->assertEquals('FormInputCategory', (new ForeignKeyField('form_input_category_id', 'form_input_categories', 'id'))->getRelatedModelClass()); - } - - public function test_it_can_generate_foreign_key_field_assignment_statement(): void - { - $this->assertEquals('category()->associate($request->input(\'category\'))', (new ForeignKeyField('category_id', 'categories', 'id'))->renderAssignment()); - } - - public function test_it_can_generate_the_correct_relation_name(): void - { - $this->assertEquals('category', (new ForeignKeyField('category_id', 'categories', 'id'))->getRelationName()); - $this->assertEquals('postType', (new ForeignKeyField('post_type_id', 'post_types', 'id'))->getRelationName()); - $this->assertEquals('formInputCategory', (new ForeignKeyField('form_input_category_id', 'form_input_categories', 'id'))->getRelationName()); - $this->assertEquals('nationality', (new ForeignKeyField('nationality', 'countries', 'code'))->getRelationName()); - $this->assertEquals('productSlug', (new ForeignKeyField('product_slug', 'products', 'slug'))->getRelationName()); - $this->assertEquals('id', (new ForeignKeyField('_id', 'countries', 'code'))->getRelationName()); - } - - public function test_it_can_generate_the_correct_input_name(): void - { - $this->assertEquals('category', (new ForeignKeyField('category_id', 'categories', 'id'))->getInputName()); - $this->assertEquals('post_type', (new ForeignKeyField('post_type_id', 'post_types', 'id'))->getInputName()); - $this->assertEquals('form_input_category', (new ForeignKeyField('form_input_category_id', 'form_input_categories', 'id'))->getInputName()); - $this->assertEquals('nationality', (new ForeignKeyField('nationality', 'countries', 'code'))->getInputName()); - $this->assertEquals('_id', (new ForeignKeyField('_id', 'countries', 'code'))->getInputName()); - } - - public function test_it_can_generate_the_correct_model_creation_statement(): void - { - $this->assertEquals('$new_category = $this->getFactory(Category::class)->create();', (new ForeignKeyField('category_id', 'categories', 'id'))->generateTestFactoryStatement('new_')); - $this->assertEquals('$old_nationality = $this->getFactory(Country::class)->create();', (new ForeignKeyField('nationality', 'countries', 'code'))->generateTestFactoryStatement('old_')); - } - - public function test_it_can_generate_the_correct_relation_statement(): void - { - $this->assertEquals('belongsTo(Category::class)', (new ForeignKeyField('category_id', 'categories', 'id'))->generateRelationStatement()); - $this->assertEquals('belongsTo(PostType::class)', (new ForeignKeyField('post_type_id', 'post_types', 'id'))->generateRelationStatement()); - $this->assertEquals('belongsTo(FormInputCategory::class)', (new ForeignKeyField('form_input_category_id', 'form_input_categories', 'id'))->generateRelationStatement()); - $this->assertEquals('belongsTo(Country::class, \'nationality\', \'code\')', (new ForeignKeyField('nationality', 'countries', 'code'))->generateRelationStatement()); - $this->assertEquals('belongsTo(Country::class, \'nationality\')', (new ForeignKeyField('nationality', 'countries', 'id'))->generateRelationStatement()); - } -} diff --git a/tests/Unit/Generators/ApiControllerGeneratorTest.php b/tests/Unit/Generators/ApiControllerGeneratorTest.php deleted file mode 100644 index 94b7780..0000000 --- a/tests/Unit/Generators/ApiControllerGeneratorTest.php +++ /dev/null @@ -1,42 +0,0 @@ -getTestStubContents('Controllers/Api/CategoriesController.php'); - $actual_content = $api_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_an_api_controller_with_foreign_keys(): void - { - $api_generator = new ApiControllerGenerator('products'); - - $expected_content = $this->getTestStubContents('Controllers/Api/ProductsController.php'); - $actual_content = $api_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_api_routes(): void - { - $api_generator = new ApiControllerGenerator('categories'); - - $expected_content = $this->getTestStubContents('routes/_api.stub'); - $actual_content = $api_generator->renderRoutes(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/ApiTestGeneratorTest.php b/tests/Unit/Generators/ApiTestGeneratorTest.php deleted file mode 100644 index fdd6aff..0000000 --- a/tests/Unit/Generators/ApiTestGeneratorTest.php +++ /dev/null @@ -1,22 +0,0 @@ -getTestStubContents('tests/Api/CategoriesControllerTest.stub'); - $actual_content = $api_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/Auth/AuthConfigGeneratorTest.php b/tests/Unit/Generators/Auth/AuthConfigGeneratorTest.php deleted file mode 100644 index 0d1e787..0000000 --- a/tests/Unit/Generators/Auth/AuthConfigGeneratorTest.php +++ /dev/null @@ -1,88 +0,0 @@ -getTestStubContents('config/auth/_guards.stub'); - $actual_content = $config_generator->renderGuards(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_auth_providers_config(): void - { - $config_generator = new AuthConfigGenerator('customers'); - - $expected_content = $this->getTestStubContents('config/auth/_providers.stub'); - $actual_content = $config_generator->renderProviders(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_auth_passwords_config(): void - { - $config_generator = new AuthConfigGenerator('customers'); - - $expected_content = $this->getTestStubContents('config/auth/_passwords.stub'); - $actual_content = $config_generator->renderPasswords(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_auth_passport_guards_config(): void - { - $config_generator = new AuthConfigGenerator('customers'); - - $expected_content = $this->getTestStubContents('config/auth/_passport-guards.stub'); - $actual_content = $config_generator->renderPassportGuards(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_auth_default_guard_config(): void - { - $config_generator = new AuthConfigGenerator('customers'); - - $expected_content = $this->getTestStubContents('config/auth/_default-guard.stub'); - $actual_content = $config_generator->renderDefaultGuard(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_auth_default_passwords_config(): void - { - $config_generator = new AuthConfigGenerator('customers'); - - $expected_content = $this->getTestStubContents('config/auth/_default-passwords.stub'); - $actual_content = $config_generator->renderDefaultPasswords(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_determine_whether_default_guard_should_be_generated(): void - { - $config_generator = new AuthConfigGenerator('customers'); - - $this->assertTrue($config_generator->shouldRenderDefaultGuard()); - - Config::set('auth.defaults.guard', 'web_admin'); - - $this->assertTrue($config_generator->shouldRenderDefaultGuard()); - - Config::set('auth.defaults.guard', 'web_customer'); - - $this->assertFalse($config_generator->shouldRenderDefaultGuard()); - } -} diff --git a/tests/Unit/Generators/Auth/AuthFactoryGeneratorTest.php b/tests/Unit/Generators/Auth/AuthFactoryGeneratorTest.php deleted file mode 100644 index b8adf4a..0000000 --- a/tests/Unit/Generators/Auth/AuthFactoryGeneratorTest.php +++ /dev/null @@ -1,22 +0,0 @@ -getTestStubContents('factories/CustomerFactory.php'); - $actual_content = $factory_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/Auth/AuthModelGeneratorTest.php b/tests/Unit/Generators/Auth/AuthModelGeneratorTest.php deleted file mode 100644 index 14bc3db..0000000 --- a/tests/Unit/Generators/Auth/AuthModelGeneratorTest.php +++ /dev/null @@ -1,32 +0,0 @@ -getTestStubContents('Models/Customer.php'); - $actual_content = $model_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_an_auth_model_without_foreign_keys(): void - { - $model_generator = new AuthModelGenerator('public_users', auth_name: 'portal'); - - $expected_content = $this->getTestStubContents('Models/PublicUser.php'); - $actual_content = $model_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/Auth/AuthPasswordResetsGeneratorTest.php b/tests/Unit/Generators/Auth/AuthPasswordResetsGeneratorTest.php deleted file mode 100644 index 4604fbf..0000000 --- a/tests/Unit/Generators/Auth/AuthPasswordResetsGeneratorTest.php +++ /dev/null @@ -1,31 +0,0 @@ -travelTo('2024-04-23 12:43:31'); - - $migration_generator = new AuthPasswordResetsGenerator('customers'); - - $this->assertEquals('2024_04_23_124331_create_customer_password_reset_tokens_table', $migration_generator->getPasswordResetsMigrationName()); - } - - public function test_it_can_generate_a_password_resets_migration(): void - { - $migration_generator = new AuthPasswordResetsGenerator('customers'); - - $expected_content = $this->getTestStubContents('migrations/create_customer_password_reset_tokens_table.php'); - $actual_content = $migration_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/Auth/AuthPermissionsGeneratorTest.php b/tests/Unit/Generators/Auth/AuthPermissionsGeneratorTest.php deleted file mode 100644 index b11d20f..0000000 --- a/tests/Unit/Generators/Auth/AuthPermissionsGeneratorTest.php +++ /dev/null @@ -1,22 +0,0 @@ -getTestStubContents('seeders/_authPermissions.stub'); - $actual_content = $permissions_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/Auth/AuthPolicyGeneratorTest.php b/tests/Unit/Generators/Auth/AuthPolicyGeneratorTest.php deleted file mode 100644 index 01c40a5..0000000 --- a/tests/Unit/Generators/Auth/AuthPolicyGeneratorTest.php +++ /dev/null @@ -1,22 +0,0 @@ -getTestStubContents('Policies/CustomerPolicy.php'); - $actual_content = $policy_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/Auth/AuthRequestGeneratorTest.php b/tests/Unit/Generators/Auth/AuthRequestGeneratorTest.php deleted file mode 100644 index d3fc492..0000000 --- a/tests/Unit/Generators/Auth/AuthRequestGeneratorTest.php +++ /dev/null @@ -1,43 +0,0 @@ -assertEquals('$this->baseRules()', $request_generator->renderBaseRulesStatement()); - - $request_generator = new AuthRequestGenerator('public_users'); - - $this->assertEquals('$this->baseRules(false, false)', $request_generator->renderBaseRulesStatement()); - } - - public function test_it_can_generate_an_auth_model_request_with_one_unique_rule(): void - { - $request_generator = new AuthRequestGenerator('customers'); - - $expected_content = $this->getTestStubContents('Requests/CustomersRequest.php'); - $actual_content = $request_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_an_auth_model_request_with_no_unique_rules(): void - { - $request_generator = new AuthRequestGenerator('public_users', auth_name: 'portal'); - - $expected_content = $this->getTestStubContents('Requests/PublicUsersRequest.php'); - $actual_content = $request_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/Auth/BaseAuthGeneratorTest.php b/tests/Unit/Generators/Auth/BaseAuthGeneratorTest.php deleted file mode 100644 index dbb9e85..0000000 --- a/tests/Unit/Generators/Auth/BaseAuthGeneratorTest.php +++ /dev/null @@ -1,195 +0,0 @@ -deleteFile($this->app->databasePath('seeders/DefaultUsersSeeder.php')); - } - - protected function tearDown(): void - { - $this->deleteFile($this->app->databasePath('seeders/DefaultUsersSeeder.php')); - - parent::tearDown(); - } - - public function test_it_can_determine_if_the_table_has_additional_attributes(): void - { - $generator = new MockAuthBaseGenerator('customers'); - - $this->assertTrue($generator->hasAdditionalAttributes()); - - $generator = new MockAuthBaseGenerator('public_users'); - - $this->assertFalse($generator->hasAdditionalAttributes()); - } - - public function test_it_can_determine_if_email_is_required(): void - { - $generator = new MockAuthBaseGenerator('customers'); - - $this->assertTrue($generator->requiresEmail()); - - $generator = new MockAuthBaseGenerator('public_users'); - - $this->assertFalse($generator->requiresEmail()); - } - - public function test_it_can_determine_if_password_is_required(): void - { - $generator = new MockAuthBaseGenerator('customers'); - - $this->assertTrue($generator->requiresPassword()); - - $generator = new MockAuthBaseGenerator('public_users'); - - $this->assertFalse($generator->requiresPassword()); - } - - public function test_it_can_extract_default_password_from_default_users_seeder(): void - { - $seeder_path = $this->app->databasePath('seeders/DefaultUsersSeeder.php'); - - $this->copyFile($this->getTestStubPath('seeders/DefaultUsersSeeder.php'), $seeder_path); - - $generator = new MockAuthBaseGenerator('customers'); - - $this->assertEquals('RandomPassword12345', $generator->getDefaultPassword()); - } - - public function test_it_can_generate_default_password_from_table_name(): void - { - $generator = new MockAuthBaseGenerator('customers'); - - $this->assertEquals('Customer@123456', $generator->getDefaultPassword()); - - } - - public function test_it_can_generate_auth_name_from_table_name(): void - { - $generator = new MockAuthBaseGenerator('customers'); - - $this->assertEquals('customer', $generator->getAuthName()); - $this->assertEquals('Customer', $generator->getNamespace()); - $this->assertEquals('web_customer', $generator->getWebGuardName()); - $this->assertEquals('api_customer', $generator->getApiGuardName()); - - } - - public function test_it_can_generate_custom_auth_name(): void - { - $generator = new MockAuthBaseGenerator('customers', auth_name: 'Portal'); - - $this->assertEquals('portal', $generator->getAuthName()); - $this->assertEquals('Portal', $generator->getNamespace()); - $this->assertEquals('web_customer', $generator->getWebGuardName()); - $this->assertEquals('api_customer', $generator->getApiGuardName()); - - } - - public function test_it_can_remove_auth_columns_even_if_no_columns_are_provided(): void - { - $generator = new MockAuthBaseGenerator('customers'); - - $skip_columns = [ - 'name', - 'email', - 'email_verified_at', - 'password', - 'remember_token', - 'last_login_at', - 'login_attempts', - 'require_password_update', - 'status', - 'new_email', - 'created_at', - 'updated_at', - 'deleted_at' - ]; - - foreach ($skip_columns as $key) { - $this->assertArrayNotHasKey($key, $generator->getFields()); - } - - $extra_columns = [ - 'designation', - 'address', - 'on_sale', - 'expire_at', - ]; - - foreach ($extra_columns as $key) { - $this->assertArrayHasKey($key, $generator->getFields()); - } - } - - public function test_it_can_remove_auth_columns_from_the_columns_that_are_provided(): void - { - $extra_columns = [ - 'address', - 'on_sale', - ]; - - $generator = new MockAuthBaseGenerator('customers', $extra_columns); - - $skip_columns = [ - 'designation', - 'expire_at', - ]; - - foreach ($skip_columns as $key) { - $this->assertArrayNotHasKey($key, $generator->getFields()); - } - - foreach ($extra_columns as $key) { - $this->assertArrayHasKey($key, $generator->getFields()); - } - } - - public function test_it_can_determine_what_is_an_auth_column(): void - { - $generator = new MockAuthBaseGenerator('customers'); - - $this->assertTrue($generator->isAuthColumn('name')); - $this->assertFalse($generator->isAuthColumn('designation')); - - Config::set('generators.auth_skip_columns', ['email']); - - $this->assertFalse($generator->isAuthColumn('name')); - } - - -} diff --git a/tests/Unit/Generators/Auth/Controllers/AuthConfirmPasswordControllerGeneratorTest.php b/tests/Unit/Generators/Auth/Controllers/AuthConfirmPasswordControllerGeneratorTest.php deleted file mode 100644 index c7ae1e7..0000000 --- a/tests/Unit/Generators/Auth/Controllers/AuthConfirmPasswordControllerGeneratorTest.php +++ /dev/null @@ -1,22 +0,0 @@ -getTestStubContents('Controllers/Customer/Auth/ConfirmPasswordController.php'); - $actual_content = $controller_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/Auth/Controllers/AuthControllerGeneratorTest.php b/tests/Unit/Generators/Auth/Controllers/AuthControllerGeneratorTest.php deleted file mode 100644 index 2697a6f..0000000 --- a/tests/Unit/Generators/Auth/Controllers/AuthControllerGeneratorTest.php +++ /dev/null @@ -1,22 +0,0 @@ -getTestStubContents('Controllers/CustomersController.php'); - $actual_content = $controller_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/Auth/Controllers/AuthForgotPasswordControllerGeneratorTest.php b/tests/Unit/Generators/Auth/Controllers/AuthForgotPasswordControllerGeneratorTest.php deleted file mode 100644 index c75252d..0000000 --- a/tests/Unit/Generators/Auth/Controllers/AuthForgotPasswordControllerGeneratorTest.php +++ /dev/null @@ -1,22 +0,0 @@ -getTestStubContents('Controllers/Customer/Auth/ForgotPasswordController.php'); - $actual_content = $controller_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/Auth/Controllers/AuthHomeControllerGeneratorTest.php b/tests/Unit/Generators/Auth/Controllers/AuthHomeControllerGeneratorTest.php deleted file mode 100644 index 2a08e69..0000000 --- a/tests/Unit/Generators/Auth/Controllers/AuthHomeControllerGeneratorTest.php +++ /dev/null @@ -1,22 +0,0 @@ -getTestStubContents('Controllers/Customer/HomeController.php'); - $actual_content = $controller_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/Auth/Controllers/AuthLoginControllerGeneratorTest.php b/tests/Unit/Generators/Auth/Controllers/AuthLoginControllerGeneratorTest.php deleted file mode 100644 index 3254f31..0000000 --- a/tests/Unit/Generators/Auth/Controllers/AuthLoginControllerGeneratorTest.php +++ /dev/null @@ -1,22 +0,0 @@ -getTestStubContents('Controllers/Customer/Auth/LoginController.php'); - $actual_content = $controller_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/Auth/Controllers/AuthRegisterControllerGeneratorTest.php b/tests/Unit/Generators/Auth/Controllers/AuthRegisterControllerGeneratorTest.php deleted file mode 100644 index 0099981..0000000 --- a/tests/Unit/Generators/Auth/Controllers/AuthRegisterControllerGeneratorTest.php +++ /dev/null @@ -1,42 +0,0 @@ -getTestStubContents('Controllers/Customer/Auth/RegisterController.php'); - $actual_content = $controller_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_register_assignments(): void - { - $controller_generator = new AuthRegisterControllerGenerator('customers'); - - $expected_content = '$customer->category()->associate($data[\'category\']);'; - $actual_content = $controller_generator->renderRegisterAssignment('category_id'); - - $this->assertEquals($expected_content, $actual_content); - - $expected_content = '$customer->on_sale = $data[\'on_sale\'] ?? false;'; - $actual_content = $controller_generator->renderRegisterAssignment('on_sale'); - - $this->assertEquals($expected_content, $actual_content); - - $expected_content = '$customer->designation = $data[\'designation\'];'; - $actual_content = $controller_generator->renderRegisterAssignment('designation'); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/Auth/Controllers/AuthResetPasswordControllerGeneratorTest.php b/tests/Unit/Generators/Auth/Controllers/AuthResetPasswordControllerGeneratorTest.php deleted file mode 100644 index b89525f..0000000 --- a/tests/Unit/Generators/Auth/Controllers/AuthResetPasswordControllerGeneratorTest.php +++ /dev/null @@ -1,22 +0,0 @@ -getTestStubContents('Controllers/Customer/Auth/ResetPasswordController.php'); - $actual_content = $controller_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/Auth/Controllers/AuthUpdatePasswordControllerGeneratorTest.php b/tests/Unit/Generators/Auth/Controllers/AuthUpdatePasswordControllerGeneratorTest.php deleted file mode 100644 index 634f6ee..0000000 --- a/tests/Unit/Generators/Auth/Controllers/AuthUpdatePasswordControllerGeneratorTest.php +++ /dev/null @@ -1,22 +0,0 @@ -getTestStubContents('Controllers/Customer/Auth/UpdatePasswordController.php'); - $actual_content = $controller_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/Auth/Controllers/AuthVerificationControllerGeneratorTest.php b/tests/Unit/Generators/Auth/Controllers/AuthVerificationControllerGeneratorTest.php deleted file mode 100644 index ea89a3a..0000000 --- a/tests/Unit/Generators/Auth/Controllers/AuthVerificationControllerGeneratorTest.php +++ /dev/null @@ -1,22 +0,0 @@ -getTestStubContents('Controllers/Customer/Auth/VerificationController.php'); - $actual_content = $controller_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/BaseGeneratorTest.php b/tests/Unit/Generators/BaseGeneratorTest.php deleted file mode 100644 index c96d0ad..0000000 --- a/tests/Unit/Generators/BaseGeneratorTest.php +++ /dev/null @@ -1,174 +0,0 @@ - 'Javaabu\\Generators\\GeneratorsServiceProvider', - '--tag' => 'generators-stubs', - ]); - - // setup skeleton service provider - $this->copyFile( - $this->getTestStubPath('Exports/ModelExport.stub'), - $this->app->basePath('stubs/vendor/generators/Exports/ModelExport.stub') - ); - } - - protected function tearDown(): void - { - $this->deleteDirectory($this->app->basePath('stubs')); - - parent::tearDown(); - } - - public function test_it_can_generate_from_custom_stubs(): void - { - $export_generator = new ExportGenerator('products'); - - $expected_content = $this->getTestStubContents('Exports/ProductsExport.php'); - $actual_content = $export_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_determine_if_the_model_has_any_fillable_fields(): void - { - $generator = new MockBaseGenerator('products'); - $this->assertTrue($generator->hasAnyFillable()); - - $generator = new MockBaseGenerator('all_foreigns'); - $this->assertFalse($generator->hasAnyFillable()); - } - - public function test_it_can_determine_the_name_field(): void - { - $generator = new MockBaseGenerator('products'); - $this->assertEquals('name', $generator->getNameField()); - - $generator = new MockBaseGenerator('orders'); - $this->assertEquals('order_no', $generator->getNameField()); - - $generator = new MockBaseGenerator('posts'); - $this->assertEquals('title', $generator->getNameField()); - - $generator = new MockBaseGenerator('payments'); - $this->assertEquals('id', $generator->getNameField()); - - $generator = new MockBaseGenerator('certificates'); - $this->assertEquals('title', $generator->getNameField()); - } - - public function test_it_can_determine_the_type_from_attribute(): void - { - $generator = new MockBaseGenerator('products'); - - $this->assertInstanceOf(StringField::class, $generator->getField('name')); - $this->assertInstanceOf(StringField::class, $generator->getField('address')); - $this->assertInstanceOf(TextField::class, $generator->getField('description')); - $this->assertInstanceOf(DecimalField::class, $generator->getField('price')); - $this->assertInstanceOf(IntegerField::class, $generator->getField('stock')); - $this->assertInstanceOf(BooleanField::class, $generator->getField('on_sale')); - $this->assertInstanceOf(JsonField::class, $generator->getField('features')); - $this->assertInstanceOf(EnumField::class, $generator->getField('status')); - $this->assertInstanceOf(ForeignKeyField::class, $generator->getField('category_id')); - $this->assertInstanceOf(DateTimeField::class, $generator->getField('published_at')); - $this->assertInstanceOf(DateField::class, $generator->getField('released_on')); - $this->assertInstanceOf(TimeField::class, $generator->getField('sale_time')); - $this->assertInstanceOf(YearField::class, $generator->getField('manufactured_year')); - } - - public function test_it_can_determine_the_label_from_the_attribute(): void - { - $generator = new MockBaseGenerator('products'); - - $this->assertEquals('Name', $generator->getField('name')->getLabel()); - $this->assertEquals('Address', $generator->getField('address')->getLabel()); - $this->assertEquals('Description', $generator->getField('description')->getLabel()); - $this->assertEquals('Price', $generator->getField('price')->getLabel()); - $this->assertEquals('Stock', $generator->getField('stock')->getLabel()); - $this->assertEquals('On Sale', $generator->getField('on_sale')->getLabel()); - $this->assertEquals('Features', $generator->getField('features')->getLabel()); - $this->assertEquals('Status', $generator->getField('status')->getLabel()); - $this->assertEquals('Category', $generator->getField('category_id')->getLabel()); - $this->assertEquals('Published At', $generator->getField('published_at')->getLabel()); - $this->assertEquals('Released On', $generator->getField('released_on')->getLabel()); - $this->assertEquals('Sale Time', $generator->getField('sale_time')->getLabel()); - $this->assertEquals('Manufactured Year', $generator->getField('manufactured_year')->getLabel()); - } - - public function test_it_can_identify_the_fillable_attributes(): void - { - $generator = new MockBaseGenerator('products'); - - $this->assertEquals([ - 'name', - 'address', - 'slug', - 'description', - 'price', - 'stock', - 'on_sale', - 'features', - 'published_at', - 'expire_at', - 'released_on', - 'sale_time', - 'status', - 'manufactured_year', - ], $generator->getFillableAttributes()); - } - - public function test_it_can_identify_the_searchable_attributes(): void - { - $generator = new MockBaseGenerator('products'); - - $this->assertEquals([ - 'name', - 'address', - 'slug', - ], $generator->getSearchableAttributes()); - } - - public function test_it_can_identify_the_foreign_key_attributes(): void - { - $generator = new MockBaseGenerator('products'); - - $this->assertEquals([ - 'category_id', - ], $generator->getForeignKeyAttributes()); - } -} diff --git a/tests/Unit/Generators/Concerns/GeneratesAuthControllerTest.php b/tests/Unit/Generators/Concerns/GeneratesAuthControllerTest.php deleted file mode 100644 index 80bed98..0000000 --- a/tests/Unit/Generators/Concerns/GeneratesAuthControllerTest.php +++ /dev/null @@ -1,26 +0,0 @@ -assertEquals('ConfirmPasswordController', $controller_generator->getControllerName()); - } - - public function test_it_can_generate_auth_controller_path(): void - { - $controller_generator = new AuthConfirmPasswordControllerGenerator('customers'); - - $this->assertEquals('Http/Controllers/Customer/Auth', $controller_generator->getControllerPath()); - } -} diff --git a/tests/Unit/Generators/ControllerGeneratorTest.php b/tests/Unit/Generators/ControllerGeneratorTest.php deleted file mode 100644 index d1ed7f6..0000000 --- a/tests/Unit/Generators/ControllerGeneratorTest.php +++ /dev/null @@ -1,82 +0,0 @@ -getTestStubContents('Controllers/_categoryForeignKey.stub'); - $actual_content = $controller_generator->renderControllerForeignKey('category_id', $controller_generator->getField('category_id')); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_boolean_assignment(): void - { - $controller_generator = new ControllerGenerator('products'); - - $expected_content = $this->getTestStubContents('Controllers/_onSaleBoolean.stub'); - $actual_content = $controller_generator->renderControllerBoolean('on_sale', $controller_generator->getField('on_sale')); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_an_orderby(): void - { - $controller_generator = new ControllerGenerator('products'); - - $expected_content = $this->getTestStubContents('Controllers/_orderBy.stub'); - $actual_content = $controller_generator->renderControllerOrderBy('created_at'); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_controller_without_foreign_keys(): void - { - $controller_generator = new ControllerGenerator('categories'); - - $expected_content = $this->getTestStubContents('Controllers/CategoriesController.php'); - $actual_content = $controller_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_controller_with_foreign_keys(): void - { - $controller_generator = new ControllerGenerator('products'); - - $expected_content = $this->getTestStubContents('Controllers/ProductsController.php'); - $actual_content = $controller_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_controller_with_multiple_foreign_keys(): void - { - $controller_generator = new ControllerGenerator('orders'); - - $expected_content = $this->getTestStubContents('Controllers/OrdersController.php'); - $actual_content = $controller_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_controller_eager_loads(): void - { - $controller_generator = new ControllerGenerator('orders'); - - $expected_content = $this->getTestStubContents('Controllers/_ordersEagerLoads.stub'); - $actual_content = $controller_generator->renderControllerEagerLoads(["'category'", "'productSlug'"]); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/ExportGeneratorTest.php b/tests/Unit/Generators/ExportGeneratorTest.php deleted file mode 100644 index 355debc..0000000 --- a/tests/Unit/Generators/ExportGeneratorTest.php +++ /dev/null @@ -1,32 +0,0 @@ -getTestStubContents('Exports/CategoriesExport.php'); - $actual_content = $export_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_an_export_with_relations(): void - { - $export_generator = new ExportGenerator('all_foreigns'); - - $expected_content = $this->getTestStubContents('Exports/AllForeignsExport.php'); - $actual_content = $export_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/FactoryGeneratorTest.php b/tests/Unit/Generators/FactoryGeneratorTest.php deleted file mode 100644 index 01262bf..0000000 --- a/tests/Unit/Generators/FactoryGeneratorTest.php +++ /dev/null @@ -1,205 +0,0 @@ -assertNull($factory_generator->getFakerMethodFromColumnName('colorful')); - $this->assertEquals('email', $factory_generator->getFakerMethodFromColumnName('email')); - $this->assertEquals('firstName', $factory_generator->getFakerMethodFromColumnName('first_name')); - } - - public function test_it_can_determine_the_faker_statement_for_faker_attributes(): void - { - $factory_generator = new FactoryGenerator('products'); - - $this->assertEquals('fake()->address()', $factory_generator->getFakerStatement('address')); - } - - public function test_it_can_determine_the_faker_statement_for_nullable_attributes(): void - { - $factory_generator = new FactoryGenerator('products'); - - $this->assertEquals('fake()->optional()->sentences(3, true)', $factory_generator->getFakerStatement('description')); - } - - public function test_it_can_determine_the_faker_statement_for_decimals(): void - { - $factory_generator = new FactoryGenerator('products'); - - $this->assertEquals('fake()->randomFloat(2, 0, 999999999999)', $factory_generator->getFakerStatement('price')); - } - - public function test_it_can_determine_the_faker_statement_for_ints(): void - { - $factory_generator = new FactoryGenerator('products'); - - $this->assertEquals('fake()->numberBetween(0, 4294967295)', $factory_generator->getFakerStatement('stock')); - } - - public function test_it_can_determine_the_faker_statement_for_texts(): void - { - $factory_generator = new FactoryGenerator('products'); - - $this->assertEquals('fake()->optional()->sentences(3, true)', $factory_generator->getFakerStatement('description')); - } - - public function test_it_can_determine_the_faker_statement_for_unique_fields(): void - { - $factory_generator = new FactoryGenerator('products'); - - $this->assertEquals('fake()->unique()->slug()', $factory_generator->getFakerStatement('slug')); - } - - public function test_it_can_determine_the_faker_statement_for_strings_shorter_than_5_characters(): void - { - $factory_generator = new FactoryGenerator('orders'); - - $this->assertEquals('fake()->regexify(\'[a-z]{4}\')', $factory_generator->getFakerStatement('order_no')); - } - - public function test_it_can_determine_the_faker_statement_for_strings(): void - { - $factory_generator = new FactoryGenerator('products'); - - $this->assertEquals('fake()->text(fake()->numberBetween(5, 255))', $factory_generator->getFakerStatement('name')); - } - - public function test_it_can_determine_the_faker_statement_for_booleans(): void - { - $factory_generator = new FactoryGenerator('products'); - - $this->assertEquals('fake()->boolean()', $factory_generator->getFakerStatement('on_sale')); - } - - - public function test_it_can_determine_the_faker_statement_for_date_times(): void - { - $factory_generator = new FactoryGenerator('products'); - - $this->assertEquals('fake()->dateTime()?->format(\'Y-m-d H:i\')', $factory_generator->getFakerStatement('published_at')); - } - - public function test_it_can_determine_the_faker_statement_for_times(): void - { - $factory_generator = new FactoryGenerator('products'); - - $this->assertEquals('fake()->time()', $factory_generator->getFakerStatement('sale_time')); - } - - public function test_it_can_determine_the_faker_statement_for_timestamps(): void - { - $factory_generator = new FactoryGenerator('products'); - - $this->assertEquals('fake()->dateTime()?->format(\'Y-m-d H:i\')', $factory_generator->getFakerStatement('expire_at')); - } - - public function test_it_can_determine_the_faker_statement_for_dates(): void - { - $factory_generator = new FactoryGenerator('products'); - - $this->assertEquals('fake()->date()', $factory_generator->getFakerStatement('released_on')); - } - - public function test_it_can_determine_the_faker_statement_for_years(): void - { - $factory_generator = new FactoryGenerator('products'); - - $this->assertEquals('fake()->year(2100)', $factory_generator->getFakerStatement('manufactured_year')); - } - - public function test_it_can_determine_the_faker_statement_for_foreign_keys(): void - { - $factory_generator = new FactoryGenerator('products'); - - $this->assertEquals('fake()'."->optional()->passThrough(random_id_or_generate(\App\Models\Category::class, 'id', generate: true))", $factory_generator->getFakerStatement('category_id')); - } - - public function test_it_can_determine_the_faker_statement_for_unique_foreign_keys(): void - { - $factory_generator = new FactoryGenerator('payments'); - - $this->assertEquals('fake()'."->unique()->passThrough(random_id_or_generate(\App\Models\Order::class, 'id', generate: true, unique: true))", $factory_generator->getFakerStatement('order_id')); - } - - public function test_it_can_determine_the_faker_statement_for_json_fields(): void - { - $factory_generator = new FactoryGenerator('products'); - - $this->assertEquals('fake()->words()', $factory_generator->getFakerStatement('features')); - } - - public function test_it_can_determine_the_faker_statement_for_enum_fields(): void - { - $factory_generator = new FactoryGenerator('products'); - - $this->assertEquals('fake()->randomElement('."['draft', 'published']".')', $factory_generator->getFakerStatement('status')); - } - - public function test_it_can_determine_the_faker_statement_for_enum_fields_with_enum_class(): void - { - $factory_generator = new FactoryGenerator('orders'); - - $this->assertEquals('fake()->randomElement(array_column(\\Javaabu\\Generators\\Tests\\TestSupport\\Enums\\OrderStatuses::cases(), \'value\'))', $factory_generator->getFakerStatement('status')); - } - - public function test_it_can_generate_a_factory_with_foreign_keys(): void - { - $factory_generator = new FactoryGenerator('products'); - - $expected_content = $this->getTestStubContents('factories/ProductFactory.php'); - $actual_content = $factory_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_factory_without_foreign_keys(): void - { - $factory_generator = new FactoryGenerator('categories'); - - $expected_content = $this->getTestStubContents('factories/CategoryFactory.php'); - $actual_content = $factory_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_factory_with_multiple_foreign_keys(): void - { - $factory_generator = new FactoryGenerator('orders'); - - $expected_content = $this->getTestStubContents('factories/OrderFactory.php'); - $actual_content = $factory_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_factory_with_unique_foreign_keys(): void - { - $factory_generator = new FactoryGenerator('payments'); - - $expected_content = $this->getTestStubContents('factories/PaymentFactory.php'); - $actual_content = $factory_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_factory_with_optional_unique_values(): void - { - $factory_generator = new FactoryGenerator('countries'); - - $expected_content = $this->getTestStubContents('factories/CountryFactory.php'); - $actual_content = $factory_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/ModelGeneratorTest.php b/tests/Unit/Generators/ModelGeneratorTest.php deleted file mode 100644 index 602d411..0000000 --- a/tests/Unit/Generators/ModelGeneratorTest.php +++ /dev/null @@ -1,72 +0,0 @@ -getTestStubContents('Models/_categoryForeignKey.stub'); - $actual_content = $model_generator->renderModelForeignKey('category_id', $model_generator->getField('category_id')); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_date_mutator(): void - { - $model_generator = new ModelGenerator('products'); - - $expected_content = $this->getTestStubContents('Models/_dateMutator.stub'); - $actual_content = $model_generator->renderModelDateMutator('published_at', $model_generator->getField('published_at')); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_nullable_date_mutator(): void - { - $model_generator = new ModelGenerator('posts'); - - $expected_content = $this->getTestStubContents('Models/_nullableDateMutator.stub'); - $actual_content = $model_generator->renderModelDateMutator('published_at', $model_generator->getField('published_at')); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_model_with_foreign_keys(): void - { - $model_generator = new ModelGenerator('products'); - - $expected_content = $this->getTestStubContents('Models/Product.php'); - $actual_content = $model_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_model_without_foreign_keys(): void - { - $model_generator = new ModelGenerator('categories'); - - $expected_content = $this->getTestStubContents('Models/Category.php'); - $actual_content = $model_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_model_with_multiple_foreign_keys(): void - { - $model_generator = new ModelGenerator('orders'); - - $expected_content = $this->getTestStubContents('Models/Order.php'); - $actual_content = $model_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/PermissionsGeneratorTest.php b/tests/Unit/Generators/PermissionsGeneratorTest.php deleted file mode 100644 index 673f89c..0000000 --- a/tests/Unit/Generators/PermissionsGeneratorTest.php +++ /dev/null @@ -1,32 +0,0 @@ -getTestStubContents('seeders/_permissionsSoftDeletes.stub'); - $actual_content = $permissions_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_permissions_for_a_none_soft_delete(): void - { - $permissions_generator = new PermissionsGenerator('categories'); - - $expected_content = $this->getTestStubContents('seeders/_permissions.stub'); - $actual_content = $permissions_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/PolicyGeneratorTest.php b/tests/Unit/Generators/PolicyGeneratorTest.php deleted file mode 100644 index 8033b0f..0000000 --- a/tests/Unit/Generators/PolicyGeneratorTest.php +++ /dev/null @@ -1,32 +0,0 @@ -getTestStubContents('Policies/ProductPolicy.php'); - $actual_content = $policy_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_policy_for_a_none_soft_delete(): void - { - $policy_generator = new PolicyGenerator('categories'); - - $expected_content = $this->getTestStubContents('Policies/CategoryPolicy.php'); - $actual_content = $policy_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/RequestGeneratorTest.php b/tests/Unit/Generators/RequestGeneratorTest.php deleted file mode 100644 index 4a80c9c..0000000 --- a/tests/Unit/Generators/RequestGeneratorTest.php +++ /dev/null @@ -1,172 +0,0 @@ -assertContains('nullable', $request_generator->getRequestValidationRules('description')); - } - - public function test_it_can_determine_the_validation_rules_for_decimals(): void - { - $request_generator = new RequestGenerator('products'); - - $this->assertEquals([ - 'decimal:0,2', - 'min:0', - 'max:999999999999' - ], $request_generator->getRequestValidationRules('price')); - } - - public function test_it_can_determine_the_validation_rules_for_ints(): void - { - $request_generator = new RequestGenerator('products'); - - $this->assertEquals([ - 'integer', - 'min:0', - 'max:4294967295' - ], $request_generator->getRequestValidationRules('stock')); - } - - public function test_it_can_determine_the_validation_rules_for_texts(): void - { - $request_generator = new RequestGenerator('products'); - - $this->assertEquals([ - 'nullable', - 'string' - ], $request_generator->getRequestValidationRules('description')); - } - - public function test_it_can_determine_the_validation_rules_for_strings(): void - { - $request_generator = new RequestGenerator('products'); - - $this->assertEquals([ - 'string', - 'max:255' - ], $request_generator->getRequestValidationRules('name')); - } - - public function test_it_can_determine_the_validation_rules_for_booleans(): void - { - $request_generator = new RequestGenerator('products'); - - $this->assertEquals([ - 'boolean' - ], $request_generator->getRequestValidationRules('on_sale')); - } - - - public function test_it_can_determine_the_validation_rules_for_date_times(): void - { - $request_generator = new RequestGenerator('products'); - - $this->assertEquals([ - 'date', - ], $request_generator->getRequestValidationRules('published_at')); - } - - public function test_it_can_determine_the_validation_rules_for_times(): void - { - $request_generator = new RequestGenerator('products'); - - $this->assertEquals([ - 'date', - ], $request_generator->getRequestValidationRules('sale_time')); - } - - public function test_it_can_determine_the_validation_rules_for_timestamps(): void - { - $request_generator = new RequestGenerator('products'); - - $this->assertEquals([ - 'date', - ], $request_generator->getRequestValidationRules('expire_at')); - } - - public function test_it_can_determine_the_validation_rules_for_dates(): void - { - $request_generator = new RequestGenerator('products'); - - $this->assertEquals([ - 'date', - ], $request_generator->getRequestValidationRules('released_on')); - } - - public function test_it_can_determine_the_validation_rules_for_years(): void - { - $request_generator = new RequestGenerator('products'); - - $this->assertEquals([ - 'integer', - 'min:1900', - 'max:2100' - ], $request_generator->getRequestValidationRules('manufactured_year')); - } - - public function test_it_can_determine_the_validation_rules_for_foreign_keys(): void - { - $request_generator = new RequestGenerator('products'); - - $this->assertEquals([ - 'nullable', - 'exists:categories,id', - ], $request_generator->getRequestValidationRules('category_id')); - } - - public function test_it_can_determine_the_validation_rules_for_json_fields(): void - { - $request_generator = new RequestGenerator('products'); - - $this->assertEquals(['array'], $request_generator->getRequestValidationRules('features')); - } - - public function test_it_can_determine_the_validation_rules_for_enum_fields(): void - { - $request_generator = new RequestGenerator('products'); - - $this->assertEquals(['in:draft,published'], $request_generator->getRequestValidationRules('status')); - } - - public function test_it_can_generate_a_request_with_one_unique_rule(): void - { - $request_generator = new RequestGenerator('products'); - - $expected_content = $this->getTestStubContents('Requests/ProductsRequest.php'); - $actual_content = $request_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_request_with_multiple_unique_rules(): void - { - $request_generator = new RequestGenerator('categories'); - - $expected_content = $this->getTestStubContents('Requests/CategoriesRequest.php'); - $actual_content = $request_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_request_with_no_unique_rules(): void - { - $request_generator = new RequestGenerator('orders'); - - $expected_content = $this->getTestStubContents('Requests/OrdersRequest.php'); - $actual_content = $request_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/RoutesGeneratorTest.php b/tests/Unit/Generators/RoutesGeneratorTest.php deleted file mode 100644 index 48ae1bd..0000000 --- a/tests/Unit/Generators/RoutesGeneratorTest.php +++ /dev/null @@ -1,32 +0,0 @@ -getTestStubContents('routes/_adminSoftDeletes.stub'); - $actual_content = $routes_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_routes_for_a_none_soft_delete(): void - { - $routes_generator = new RoutesGenerator('categories'); - - $expected_content = $this->getTestStubContents('routes/_admin.stub'); - $actual_content = $routes_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/TestGeneratorTest.php b/tests/Unit/Generators/TestGeneratorTest.php deleted file mode 100644 index efdbbbd..0000000 --- a/tests/Unit/Generators/TestGeneratorTest.php +++ /dev/null @@ -1,142 +0,0 @@ -getTestStubContents('tests/_errors.stub'); - $actual_content = $test_generator->renderErrors(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_render_wrong_inputs(): void - { - $test_generator = new TestGenerator('products'); - - $expected_content = $this->getTestStubContents('tests/_wrongInputs.stub'); - $actual_content = $test_generator->renderWrongInputs(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_render_wrong_db_values(): void - { - $test_generator = new TestGenerator('products'); - - $expected_content = $this->getTestStubContents('tests/_wrongDbValues.stub'); - $actual_content = $test_generator->renderWrongDbValues(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_render_correct_inputs(): void - { - $test_generator = new TestGenerator('products'); - - $expected_content = $this->getTestStubContents('tests/_correctInputs.stub'); - $actual_content = $test_generator->renderCorrectInputs(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_render_correct_db_values(): void - { - $test_generator = new TestGenerator('products'); - - $expected_content = $this->getTestStubContents('tests/_correctDbValues.stub'); - $actual_content = $test_generator->renderCorrectDbValues(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_render_different_correct_inputs(): void - { - $test_generator = new TestGenerator('products'); - - $expected_content = $this->getTestStubContents('tests/_differentCorrectInputs.stub'); - $actual_content = $test_generator->renderDifferentCorrectInputs(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_render_different_correct_db_values(): void - { - $test_generator = new TestGenerator('products'); - - $expected_content = $this->getTestStubContents('tests/_differentCorrectDbValues.stub'); - $actual_content = $test_generator->renderDifferentCorrectDbValues(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_render_factory_inputs(): void - { - $test_generator = new TestGenerator('products'); - - $expected_content = $this->getTestStubContents('tests/_factoryInputs.stub'); - $actual_content = $test_generator->renderFactoryInputs(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_render_factory_db_values(): void - { - $test_generator = new TestGenerator('products'); - - $expected_content = $this->getTestStubContents('tests/_factoryDbValues.stub'); - $actual_content = $test_generator->renderFactoryDbValues(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_test_with_foreign_keys(): void - { - $test_generator = new TestGenerator('products'); - - $expected_content = $this->getTestStubContents('tests/ProductsControllerTest.stub'); - $actual_content = $test_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_test_without_foreign_keys(): void - { - $test_generator = new TestGenerator('categories'); - - $expected_content = $this->getTestStubContents('tests/CategoriesControllerTest.stub'); - $actual_content = $test_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_test_with_multiple_foreign_keys(): void - { - $test_generator = new TestGenerator('orders'); - - $expected_content = $this->getTestStubContents('tests/OrdersControllerTest.stub'); - $actual_content = $test_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_test_where_the_enum_has_only_one_case(): void - { - $test_generator = new TestGenerator('single_value_enum_orders'); - - $expected_content = $this->getTestStubContents('tests/SingleValueEnumOrdersControllerTest.stub'); - $actual_content = $test_generator->render(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/Generators/ViewsGeneratorTest.php b/tests/Unit/Generators/ViewsGeneratorTest.php deleted file mode 100644 index e017433..0000000 --- a/tests/Unit/Generators/ViewsGeneratorTest.php +++ /dev/null @@ -1,366 +0,0 @@ -assertEquals('', $views_generator->getFormComponentBlade('description')); - } - - public function test_it_can_generate_the_component_for_decimals(): void - { - $views_generator = new ViewsGenerator('products'); - - $this->assertEquals('', $views_generator->getFormComponentBlade('price')); - } - - public function test_it_can_generate_the_component_for_ints(): void - { - $views_generator = new ViewsGenerator('products'); - - $this->assertEquals('', $views_generator->getFormComponentBlade('stock')); - } - - public function test_it_can_generate_the_component_for_texts(): void - { - $views_generator = new ViewsGenerator('products'); - - $this->assertEquals('', $views_generator->getFormComponentBlade('description')); - } - - public function test_it_can_generate_the_component_for_strings(): void - { - $views_generator = new ViewsGenerator('products'); - - $this->assertEquals('', $views_generator->getFormComponentBlade('name')); - } - - - public function test_it_can_generate_the_component_for_booleans(): void - { - $views_generator = new ViewsGenerator('products'); - - $this->assertEquals('', $views_generator->getFormComponentBlade('on_sale')); - } - - - public function test_it_can_generate_the_component_for_date_times(): void - { - $views_generator = new ViewsGenerator('products'); - - $this->assertEquals('', $views_generator->getFormComponentBlade('published_at')); - } - - public function test_it_can_generate_the_component_for_times(): void - { - $views_generator = new ViewsGenerator('products'); - - $this->assertEquals('', $views_generator->getFormComponentBlade('sale_time')); - } - - public function test_it_can_generate_the_component_for_timestamps(): void - { - $views_generator = new ViewsGenerator('products'); - - $this->assertEquals('', $views_generator->getFormComponentBlade('expire_at')); - } - - public function test_it_can_generate_the_component_for_dates(): void - { - $views_generator = new ViewsGenerator('products'); - - $this->assertEquals('', $views_generator->getFormComponentBlade('released_on')); - } - - public function test_it_can_generate_the_component_for_years(): void - { - $views_generator = new ViewsGenerator('products'); - - $this->assertEquals('', $views_generator->getFormComponentBlade('manufactured_year')); - } - - public function test_it_can_generate_the_component_for_foreign_keys(): void - { - $views_generator = new ViewsGenerator('products'); - - $this->assertEquals('', $views_generator->getFormComponentBlade('category_id')); - } - - public function test_it_can_generate_the_component_for_json_fields(): void - { - $views_generator = new ViewsGenerator('products'); - - $this->assertEquals('', $views_generator->getFormComponentBlade('features')); - } - - public function test_it_can_generate_the_component_for_enum_fields(): void - { - $views_generator = new ViewsGenerator('products'); - - $this->assertEquals('', $views_generator->getFormComponentBlade('status')); - } - - public function test_it_can_generate_the_component_for_enum_fields_with_enum_class_that_dont_have_get_labels(): void - { - $views_generator = new ViewsGenerator('payments'); - - $this->assertEquals('', $views_generator->getFormComponentBlade('status')); - } - - public function test_it_can_generate_the_component_for_enum_fields_with_enum_class(): void - { - $views_generator = new ViewsGenerator('orders'); - - $this->assertEquals('', $views_generator->getFormComponentBlade('status')); - } - - public function test_it_can_generate_an_infolist_with_foreign_keys(): void - { - $views_generator = new ViewsGenerator('products'); - - $expected_content = $this->getTestStubContents('views/products/_details.blade.php'); - $actual_content = $views_generator->renderInfolist(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_an_infolist_with_multiple_foreign_keys(): void - { - $views_generator = new ViewsGenerator('orders'); - - $expected_content = $this->getTestStubContents('views/orders/_details.blade.php'); - $actual_content = $views_generator->renderInfolist(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_form_with_foreign_keys(): void - { - $views_generator = new ViewsGenerator('products'); - - $expected_content = $this->getTestStubContents('views/products/_form.blade.php'); - $actual_content = $views_generator->renderForm(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_form_without_foreign_keys(): void - { - $views_generator = new ViewsGenerator('categories'); - - $expected_content = $this->getTestStubContents('views/categories/_form.blade.php'); - $actual_content = $views_generator->renderForm(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_a_form_with_multiple_foreign_keys(): void - { - $views_generator = new ViewsGenerator('orders'); - - $expected_content = $this->getTestStubContents('views/orders/_form.blade.php'); - $actual_content = $views_generator->renderForm(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_model_layout(): void - { - $views_generator = new ViewsGenerator('products'); - - $expected_content = $this->getTestStubContents('views/products/products.blade.php'); - $actual_content = $views_generator->renderLayout(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_model_create_view(): void - { - $views_generator = new ViewsGenerator('products'); - - $expected_content = $this->getTestStubContents('views/products/create.blade.php'); - $actual_content = $views_generator->renderCreateView(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_model_edit_view(): void - { - $views_generator = new ViewsGenerator('products'); - - $expected_content = $this->getTestStubContents('views/products/edit.blade.php'); - $actual_content = $views_generator->renderEditView(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_model_show_view(): void - { - $views_generator = new ViewsGenerator('products'); - - $expected_content = $this->getTestStubContents('views/products/show.blade.php'); - $actual_content = $views_generator->renderShowView(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_actions_view_for_soft_delete_models(): void - { - $views_generator = new ViewsGenerator('products'); - - $expected_content = $this->getTestStubContents('views/products/_actions.blade.php'); - $actual_content = $views_generator->renderActions(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_actions_view_for_none_soft_delete_models(): void - { - $views_generator = new ViewsGenerator('categories'); - - $expected_content = $this->getTestStubContents('views/categories/_actions.blade.php'); - $actual_content = $views_generator->renderActions(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_bulk_actions_view(): void - { - $views_generator = new ViewsGenerator('products'); - - $expected_content = $this->getTestStubContents('views/products/_bulk.blade.php'); - $actual_content = $views_generator->renderBulkActions(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_filters_view_for_soft_delete_models(): void - { - $views_generator = new ViewsGenerator('products'); - - $expected_content = $this->getTestStubContents('views/products/_filter.blade.php'); - $actual_content = $views_generator->renderFilters(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_filters_view_for_none_soft_delete_models(): void - { - $views_generator = new ViewsGenerator('categories'); - - $expected_content = $this->getTestStubContents('views/categories/_filter.blade.php'); - $actual_content = $views_generator->renderFilters(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_render_model_columns_and_skips_the_admin_link_name(): void - { - $views_generator = new ViewsGenerator('products'); - - $expected_content = $this->getTestStubContents('views/products/_columns.blade.stub'); - $actual_content = $views_generator->renderTableColumns(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_list_view_for_multiple_foreign_keys_model(): void - { - $views_generator = new ViewsGenerator('orders'); - - $expected_content = $this->getTestStubContents('views/orders/_list.blade.php'); - $actual_content = $views_generator->renderTableRows(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_list_view_for_soft_delete_models(): void - { - $views_generator = new ViewsGenerator('products'); - - $expected_content = $this->getTestStubContents('views/products/_list.blade.php'); - $actual_content = $views_generator->renderTableRows(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_list_view_for_none_soft_delete_models(): void - { - $views_generator = new ViewsGenerator('categories'); - - $expected_content = $this->getTestStubContents('views/categories/_list.blade.php'); - $actual_content = $views_generator->renderTableRows(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_render_column_titles_and_skips_the_admin_link_name(): void - { - $views_generator = new ViewsGenerator('products'); - - $expected_content = $this->getTestStubContents('views/products/_titles.blade.stub'); - $actual_content = $views_generator->renderTableTitles(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_table_view_for_soft_delete_models(): void - { - $views_generator = new ViewsGenerator('products'); - - $expected_content = $this->getTestStubContents('views/products/_table.blade.php'); - $actual_content = $views_generator->renderTable(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_table_view_for_none_soft_delete_models(): void - { - $views_generator = new ViewsGenerator('categories'); - - $expected_content = $this->getTestStubContents('views/categories/_table.blade.php'); - $actual_content = $views_generator->renderTable(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_index_view_for_soft_delete_models(): void - { - $views_generator = new ViewsGenerator('products'); - - $expected_content = $this->getTestStubContents('views/products/index.blade.php'); - $actual_content = $views_generator->renderIndexView(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_generate_index_view_for_none_soft_delete_models(): void - { - $views_generator = new ViewsGenerator('categories'); - - $expected_content = $this->getTestStubContents('views/categories/index.blade.php'); - $actual_content = $views_generator->renderIndexView(); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_render_sidebar_links(): void - { - $views_generator = new ViewsGenerator('categories'); - - $expected_content = $this->getTestStubContents('Menus/_categoriesSidebar.stub'); - $actual_content = $views_generator->renderSidebarLinks(); - - $this->assertEquals($expected_content, $actual_content); - } -} diff --git a/tests/Unit/IconProviderTest.php b/tests/Unit/IconProviderTest.php deleted file mode 100644 index 382aa8a..0000000 --- a/tests/Unit/IconProviderTest.php +++ /dev/null @@ -1,32 +0,0 @@ -assertEquals('file', $icons->findIconFor('file')); - $this->assertEquals('email', $icons->findIconFor('messages')); - $this->assertEquals('shopping-cart', $icons->findIconFor('product')); - $this->assertEquals('email', $icons->findIconFor('emails')); - $this->assertEquals('cloud-upload', $icons->findIconFor('cloud_file_uploads')); - } - - public function test_it_can_find_appropriate_fontawesome_icons(): void - { - $icons = new FontAwesomeProvider(); - - $this->assertEquals('file', $icons->findIconFor('file')); - $this->assertEquals('envelope', $icons->findIconFor('messages')); - $this->assertEquals('shopping-cart', $icons->findIconFor('product')); - $this->assertEquals('crow', $icons->findIconFor('crows')); - $this->assertEquals('cloud-upload', $icons->findIconFor('cloud_file_uploads')); - } -} diff --git a/tests/Unit/Schema/RelationResolverTest.php b/tests/Unit/Schema/RelationResolverTest.php new file mode 100644 index 0000000..c871f4a --- /dev/null +++ b/tests/Unit/Schema/RelationResolverTest.php @@ -0,0 +1,95 @@ + $relations + * @return array + */ + protected function definitions(array $relations): array + { + $definitions = []; + + foreach ($relations as $relation) { + $definitions[$relation->name] = $relation->definition(); + } + + return $definitions; + } + + protected function table(string $name) + { + return app(SchemaInspector::class)->table($name); + } + + #[Test] + public function it_finds_belongs_to_relations(): void + { + $relations = app(RelationResolver::class)->belongsTo($this->table('orders')); + + $this->assertEquals([ + 'category' => 'belongsTo(Category::class)', + 'product' => "belongsTo(Product::class, 'product_slug', 'slug')", + ], $this->definitions($relations)); + } + + #[Test] + public function it_finds_has_many_relations_from_other_tables(): void + { + $definitions = $this->definitions(app(RelationResolver::class)->hasMany($this->table('categories'))); + + $this->assertEquals('hasMany(Product::class)', $definitions['products']); + $this->assertEquals('hasMany(Order::class)', $definitions['orders']); + $this->assertArrayNotHasKey('productTags', $definitions); + } + + #[Test] + public function it_finds_has_many_relations_on_other_keys(): void + { + $definitions = $this->definitions(app(RelationResolver::class)->hasMany($this->table('products'))); + + $this->assertEquals("hasMany(Order::class, 'product_slug', 'slug')", $definitions['orders']); + } + + #[Test] + public function it_finds_many_to_many_relations_through_pivot_tables(): void + { + $resolver = app(RelationResolver::class); + + $this->assertEquals(['tags' => 'belongsToMany(Tag::class)'], $this->definitions($resolver->belongsToMany($this->table('products')))); + $this->assertEquals(['products' => 'belongsToMany(Product::class)'], $this->definitions($resolver->belongsToMany($this->table('tags')))); + } + + #[Test] + public function it_tells_pivot_tables_apart(): void + { + $resolver = app(RelationResolver::class); + + $this->assertTrue($resolver->isPivotTable($this->table('product_tag'))); + + // two keys, but one is to products.slug, not a key + $this->assertFalse($resolver->isPivotTable($this->table('all_foreigns'))); + + $this->assertFalse($resolver->isPivotTable($this->table('orders'))); + $this->assertFalse($resolver->isPivotTable($this->table('products'))); + } + + #[Test] + public function it_skips_ignored_tables(): void + { + config()->set('generators.ignored_tables', ['orders']); + + $definitions = $this->definitions(app(RelationResolver::class)->hasMany($this->table('categories'))); + + $this->assertArrayNotHasKey('orders', $definitions); + $this->assertArrayHasKey('products', $definitions); + } +} diff --git a/tests/Unit/Schema/SchemaInspectorTest.php b/tests/Unit/Schema/SchemaInspectorTest.php new file mode 100644 index 0000000..01ee361 --- /dev/null +++ b/tests/Unit/Schema/SchemaInspectorTest.php @@ -0,0 +1,150 @@ +inspector()->tableNames(); + + $this->assertContains('products', $tables); + $this->assertContains('product_tag', $tables); + $this->assertTrue($this->inspector()->hasTable('categories')); + $this->assertFalse($this->inspector()->hasTable('missing')); + } + + #[Test] + public function it_throws_for_a_missing_table(): void + { + $this->expectException(TableDoesNotExistException::class); + + $this->inspector()->table('missing'); + } + + #[Test] + public function it_normalizes_the_column_types(): void + { + $table = $this->inspector()->table('products'); + + $types = array_map(fn ($column) => $column->type, $table->columns); + + $this->assertContains($types['id'], ['bigint', 'int']); + $this->assertEquals('string', $types['name']); + $this->assertEquals('text', $types['description']); + $this->assertEquals('decimal', $types['price']); + $this->assertEquals('int', $types['stock']); + $this->assertEquals('boolean', $types['on_sale']); + $this->assertEquals('date', $types['released_on']); + $this->assertEquals('time', $types['sale_time']); + $this->assertEquals('enum', $types['status']); + $this->assertContains($types['published_at'], ['datetime', 'timestamp']); + $this->assertContains($types['expire_at'], ['datetime', 'timestamp']); + + // SQLite stores JSON as text + $this->assertContains($types['features'], ['json', 'text']); + + // MySQL keeps year columns, the others store integers + $this->assertContains($types['manufactured_year'], ['year', 'int']); + } + + #[Test] + public function it_reads_enum_options_on_every_driver(): void + { + $status = $this->inspector()->table('products')->column('status'); + + $this->assertEquals('enum', $status->type); + $this->assertEquals(['draft', 'published'], $status->options); + } + + #[Test] + public function it_reads_nullability_uniqueness_and_defaults(): void + { + $table = $this->inspector()->table('products'); + + $this->assertTrue($table->column('description')->nullable); + $this->assertFalse($table->column('name')->nullable); + $this->assertTrue($table->column('slug')->unique); + $this->assertFalse($table->column('name')->unique); + $this->assertNotNull($table->column('on_sale')->default); + $this->assertTrue($table->column('id')->auto_increment); + } + + #[Test] + public function it_reads_the_decimal_precision(): void + { + $price = $this->inspector()->table('products')->column('price'); + + // SQLite keeps no precision: 10,2 is assumed + $sqlite = $this->inspector()->driver() === 'sqlite'; + + $this->assertEquals($sqlite ? 10 : 14, $price->precision); + $this->assertEquals(2, $price->scale); + } + + #[Test] + public function it_reads_string_lengths_where_the_driver_keeps_them(): void + { + $order_no = $this->inspector()->table('orders')->column('order_no'); + + $expected = $this->inspector()->driver() === 'sqlite' ? 255 : 4; + + $this->assertEquals($expected, $order_no->length); + } + + #[Test] + public function it_reads_enum_classes_from_column_comments_where_the_driver_keeps_them(): void + { + if ($this->inspector()->driver() === 'sqlite') { + $this->markTestSkipped('SQLite keeps no column comments.'); + } + + $status = $this->inspector()->table('payments')->column('status'); + + $this->assertEquals('App\\Enums\\PaymentStatuses', $status->enumClassFromComment()); + } + + #[Test] + public function it_reads_the_keys_timestamps_and_soft_deletes(): void + { + $products = $this->inspector()->table('products'); + $payments = $this->inspector()->table('payments'); + $pivot = $this->inspector()->table('product_tag'); + + $this->assertEquals('id', $products->keyName()); + $this->assertTrue($products->hasTimestamps()); + $this->assertTrue($products->hasSoftDeletes()); + + $this->assertFalse($payments->hasTimestamps()); + $this->assertTrue($payments->hasSoftDeletes()); + + $this->assertNull($pivot->keyName()); + $this->assertEquals(['product_id', 'tag_id'], $pivot->primary_key); + } + + #[Test] + public function it_reads_foreign_keys(): void + { + $orders = $this->inspector()->table('orders'); + + $category = $orders->foreignKeyFor('category_id'); + $product = $orders->foreignKeyFor('product_slug'); + + $this->assertEquals('categories', $category->foreign_table); + $this->assertEquals('id', $category->foreign_column); + $this->assertEquals('products', $product->foreign_table); + $this->assertEquals('slug', $product->foreign_column); + $this->assertNull($orders->foreignKeyFor('order_no')); + } +} diff --git a/tests/Unit/SchemaResolverMySqlTest.php b/tests/Unit/SchemaResolverMySqlTest.php deleted file mode 100644 index db7e61e..0000000 --- a/tests/Unit/SchemaResolverMySqlTest.php +++ /dev/null @@ -1,341 +0,0 @@ -assertEquals('id', $resolver->resolve()->getKeyName()); - } - - public function test_it_can_resolve_soft_deletes(): void - { - $resolver = new SchemaResolverMySql('products'); - $this->assertTrue($resolver->resolve()->hasSoftDeletes()); - - $resolver = new SchemaResolverMySql('categories'); - $this->assertFalse($resolver->resolve()->hasSoftDeletes()); - } - - public function test_it_can_resolve_timestamps(): void - { - $resolver = new SchemaResolverMySql('products'); - $this->assertTrue($resolver->resolve()->hasTimestamps()); - - $resolver = new SchemaResolverMySql('payments'); - $this->assertFalse($resolver->resolve()->hasTimestamps()); - } - - public function test_it_can_resolve_a_subset_of_fields(): void - { - $resolver = new SchemaResolverMySql('orders', ['order_no', 'category_id']); - $fields = $resolver->resolve()->getFields(); - - $this->assertArrayHasKey('order_no', $fields); - $this->assertArrayHasKey('category_id', $fields); - $this->assertArrayNotHasKey('product_slug', $fields); - $this->assertCount(2, $fields); - } - - public function test_it_does_not_resolve_always_ignored_fields(): void - { - $resolver = new SchemaResolverMySql('products'); - $fields = $resolver->resolve()->getFields(); - - $this->assertArrayNotHasKey('created_at', $fields); - $this->assertArrayNotHasKey('updated_at', $fields); - $this->assertArrayNotHasKey('deleted_at', $fields); - } - - public function test_it_does_not_resolve_auto_increments(): void - { - $resolver = new SchemaResolverMySql('products'); - $fields = $resolver->resolve()->getFields(); - - $this->assertArrayNotHasKey('id', $fields); - } - - public function test_it_can_resolve_text_fields(): void - { - $resolver = new SchemaResolverMySql('posts'); - $fields = $resolver->resolve()->getFields(); - - $this->assertArrayHasKey('excerpt', $fields); - - $field = $fields['excerpt']; - $this->assertInstanceOf(TextField::class, $field); - $this->assertEquals('excerpt', $field->getName()); - $this->assertEquals(65535, $field->getMax()); - $this->assertFalse($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertFalse($field->isUnique()); - } - - public function test_it_can_resolve_medium_text_fields(): void - { - $resolver = new SchemaResolverMySql('posts'); - $fields = $resolver->resolve()->getFields(); - - $this->assertArrayHasKey('medium_content', $fields); - - $field = $fields['medium_content']; - $this->assertInstanceOf(TextField::class, $field); - $this->assertEquals('medium_content', $field->getName()); - $this->assertEquals(16777215, $field->getMax()); - $this->assertTrue($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertFalse($field->isUnique()); - } - - public function test_it_can_resolve_tiny_text_fields(): void - { - $resolver = new SchemaResolverMySql('posts'); - $fields = $resolver->resolve()->getFields(); - - $this->assertArrayHasKey('tiny_content', $fields); - - $field = $fields['tiny_content']; - $this->assertInstanceOf(TextField::class, $field); - $this->assertEquals('tiny_content', $field->getName()); - $this->assertEquals(255, $field->getMax()); - $this->assertTrue($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertFalse($field->isUnique()); - } - - public function test_it_can_resolve_long_text_fields(): void - { - $resolver = new SchemaResolverMySql('posts'); - $fields = $resolver->resolve()->getFields(); - - $this->assertArrayHasKey('content', $fields); - - $field = $fields['content']; - $this->assertInstanceOf(TextField::class, $field); - $this->assertEquals('content', $field->getName()); - $this->assertEquals(4294967295, $field->getMax()); - $this->assertTrue($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertFalse($field->isUnique()); - } - - public function test_it_can_resolve_foreign_key_fields(): void - { - $field = $this->resolveField('category_id'); - - $this->assertInstanceOf(ForeignKeyField::class, $field); - $this->assertEquals('category_id', $field->getName()); - $this->assertEquals('categories', $field->getRelatedTable()); - $this->assertEquals('id', $field->getRelatedKeyName()); - $this->assertEquals('Category', $field->getRelatedModelClass()); - $this->assertTrue($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertFalse($field->isUnique()); - $this->assertNull($field->generateCast()); - } - - public function test_it_can_resolve_enums_from_comments(): void - { - $field = $this->resolveField('status', 'payments'); - - $this->assertInstanceOf(EnumField::class, $field); - $this->assertEquals('status', $field->getName()); - $this->assertTrue($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertFalse($field->isUnique()); - $this->assertEquals('PaymentStatuses::class', $field->generateCast()); - } - - public function test_it_can_resolve_boolean_fields(): void - { - $field = $this->resolveField('on_sale'); - - $this->assertInstanceOf(BooleanField::class, $field); - $this->assertEquals('on_sale', $field->getName()); - $this->assertFalse($field->isNullable()); - $this->assertFalse($field->getDefault()); - $this->assertTrue($field->hasDefault()); - $this->assertFalse($field->isUnique()); - $this->assertEquals('boolean', $field->generateCast()); - } - - public function test_it_can_resolve_unique_fields(): void - { - $field = $this->resolveField('slug'); - - $this->assertInstanceOf(StringField::class, $field); - $this->assertEquals('slug', $field->getName()); - $this->assertEquals(255, $field->getMax()); - $this->assertFalse($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertTrue($field->isUnique()); - } - - public function test_it_can_resolve_string_fields(): void - { - $field = $this->resolveField('name'); - - $this->assertInstanceOf(StringField::class, $field); - $this->assertEquals('name', $field->getName()); - $this->assertEquals(255, $field->getMax()); - $this->assertFalse($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertFalse($field->isUnique()); - $this->assertEquals('string', $field->generateCast()); - } - - public function test_it_can_resolve_integer_fields(): void - { - $field = $this->resolveField('stock'); - - $this->assertInstanceOf(IntegerField::class, $field); - $this->assertEquals('stock', $field->getName()); - $this->assertTrue($field->isUnsigned()); - $this->assertEquals(0, $field->getMin()); - $this->assertEquals(4294967295, $field->getMax()); - $this->assertFalse($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertFalse($field->isUnique()); - $this->assertEquals('integer', $field->generateCast()); - } - - public function test_it_can_resolve_decimal_fields(): void - { - $field = $this->resolveField('price'); - - $this->assertInstanceOf(DecimalField::class, $field); - $this->assertEquals('price', $field->getName()); - $this->assertTrue($field->isUnsigned()); - $this->assertEquals(14, $field->getTotalDigits()); - $this->assertEquals(2, $field->getPlaces()); - $this->assertEquals(0, $field->getMin()); - $this->assertEquals(999999999999, $field->getMax()); - $this->assertFalse($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertFalse($field->isUnique()); - $this->assertEquals('decimal:2', $field->generateCast()); - } - - public function test_it_can_resolve_enum_fields(): void - { - $field = $this->resolveField('status'); - - $this->assertInstanceOf(EnumField::class, $field); - $this->assertEquals('status', $field->getName()); - $this->assertEquals(['draft', 'published'], $field->getOptions()); - $this->assertFalse($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertFalse($field->isUnique()); - $this->assertNull($field->generateCast()); - } - - public function test_it_can_resolve_year_fields(): void - { - $field = $this->resolveField('manufactured_year'); - - $this->assertInstanceOf(YearField::class, $field); - $this->assertEquals('manufactured_year', $field->getName()); - $this->assertEquals(1900, $field->getMin()); - $this->assertEquals(2100, $field->getMax()); - $this->assertFalse($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertFalse($field->isUnique()); - $this->assertEquals('integer', $field->generateCast()); - } - - public function test_it_can_resolve_date_time_fields(): void - { - $field = $this->resolveField('published_at'); - - $this->assertInstanceOf(DateTimeField::class, $field); - $this->assertEquals('published_at', $field->getName()); - $this->assertNull($field->getMin()); - $this->assertNull($field->getMax()); - $this->assertFalse($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertFalse($field->isUnique()); - $this->assertEquals('datetime', $field->generateCast()); - - $field = $this->resolveField('expire_at'); - - $this->assertInstanceOf(DateTimeField::class, $field); - $this->assertEquals('expire_at', $field->getName()); - $this->assertNull($field->getMin()); - $this->assertNull($field->getMax()); - $this->assertFalse($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertFalse($field->isUnique()); - $this->assertEquals('datetime', $field->generateCast()); - } - - public function test_it_can_resolve_date_fields(): void - { - $field = $this->resolveField('released_on'); - - $this->assertInstanceOf(DateField::class, $field); - $this->assertEquals('released_on', $field->getName()); - $this->assertNull($field->getMin()); - $this->assertNull($field->getMax()); - $this->assertFalse($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertFalse($field->isUnique()); - $this->assertEquals('date', $field->generateCast()); - } - - public function test_it_can_resolve_time_fields(): void - { - $field = $this->resolveField('sale_time'); - - $this->assertInstanceOf(TimeField::class, $field); - $this->assertEquals('sale_time', $field->getName()); - $this->assertNull($field->getMin()); - $this->assertNull($field->getMax()); - $this->assertFalse($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertFalse($field->isUnique()); - $this->assertEquals('datetime', $field->generateCast()); - } - - public function test_it_can_resolve_json_fields(): void - { - $field = $this->resolveField('features'); - - $this->assertInstanceOf(JsonField::class, $field); - $this->assertEquals('features', $field->getName()); - $this->assertNull($field->getMin()); - $this->assertNull($field->getMax()); - $this->assertFalse($field->isNullable()); - $this->assertFalse($field->hasDefault()); - $this->assertFalse($field->isUnique()); - $this->assertEquals('array', $field->generateCast()); - } - - protected function resolveField($field, $table = 'products'): Field - { - $resolver = new SchemaResolverMySql($table, [$field]); - $fields = $resolver->resolve()->getFields(); - - return $fields[$field]; - } -} diff --git a/tests/Unit/StringCaserTest.php b/tests/Unit/StringCaserTest.php deleted file mode 100644 index 299ce4b..0000000 --- a/tests/Unit/StringCaserTest.php +++ /dev/null @@ -1,89 +0,0 @@ -assertEquals('formInputFields', StringCaser::pluralCamel($name)); - $this->assertEquals('form-input-fields', StringCaser::pluralKebab($name)); - $this->assertEquals('form_input_fields', StringCaser::pluralSnake($name)); - $this->assertEquals('FormInputFields', StringCaser::pluralStudly($name)); - $this->assertEquals('Form Input Fields', StringCaser::pluralTitle($name)); - $this->assertEquals('form input fields', StringCaser::pluralLower($name)); - - $this->assertEquals('formInputField', StringCaser::singularCamel($name)); - $this->assertEquals('form-input-field', StringCaser::singularKebab($name)); - $this->assertEquals('form_input_field', StringCaser::singularSnake($name)); - $this->assertEquals('FormInputField', StringCaser::singularStudly($name)); - $this->assertEquals('Form Input Field', StringCaser::singularTitle($name)); - $this->assertEquals('form input field', StringCaser::singularLower($name)); - - $this->assertEquals('formInputField', StringCaser::camel($name)); - $this->assertEquals('form-input-field', StringCaser::kebab($name)); - $this->assertEquals('form_input_field', StringCaser::snake($name)); - $this->assertEquals('FormInputField', StringCaser::studly($name)); - $this->assertEquals('Form Input Field', StringCaser::title($name)); - $this->assertEquals('form input field', StringCaser::lower($name)); - } - - public function test_it_can_generate_correct_string_cases_in_snake_format(): void - { - $name = 'Form_input_Field'; - - $this->assertEquals('formInputFields', StringCaser::pluralCamel($name)); - $this->assertEquals('form-input-fields', StringCaser::pluralKebab($name)); - $this->assertEquals('form_input_fields', StringCaser::pluralSnake($name)); - $this->assertEquals('FormInputFields', StringCaser::pluralStudly($name)); - $this->assertEquals('Form Input Fields', StringCaser::pluralTitle($name)); - $this->assertEquals('form input fields', StringCaser::pluralLower($name)); - - $this->assertEquals('formInputField', StringCaser::singularCamel($name)); - $this->assertEquals('form-input-field', StringCaser::singularKebab($name)); - $this->assertEquals('form_input_field', StringCaser::singularSnake($name)); - $this->assertEquals('FormInputField', StringCaser::singularStudly($name)); - $this->assertEquals('Form Input Field', StringCaser::singularTitle($name)); - $this->assertEquals('form input field', StringCaser::singularLower($name)); - - $this->assertEquals('formInputField', StringCaser::camel($name)); - $this->assertEquals('form-input-field', StringCaser::kebab($name)); - $this->assertEquals('form_input_field', StringCaser::snake($name)); - $this->assertEquals('FormInputField', StringCaser::studly($name)); - $this->assertEquals('Form Input Field', StringCaser::title($name)); - $this->assertEquals('form input field', StringCaser::lower($name)); - } - - public function test_it_can_generate_correct_string_cases_in_kebab_format(): void - { - $name = 'form-input-field'; - - $this->assertEquals('formInputFields', StringCaser::pluralCamel($name)); - $this->assertEquals('form-input-fields', StringCaser::pluralKebab($name)); - $this->assertEquals('form_input_fields', StringCaser::pluralSnake($name)); - $this->assertEquals('FormInputFields', StringCaser::pluralStudly($name)); - $this->assertEquals('Form Input Fields', StringCaser::pluralTitle($name)); - $this->assertEquals('form input fields', StringCaser::pluralLower($name)); - - $this->assertEquals('formInputField', StringCaser::singularCamel($name)); - $this->assertEquals('form-input-field', StringCaser::singularKebab($name)); - $this->assertEquals('form_input_field', StringCaser::singularSnake($name)); - $this->assertEquals('FormInputField', StringCaser::singularStudly($name)); - $this->assertEquals('Form Input Field', StringCaser::singularTitle($name)); - $this->assertEquals('form input field', StringCaser::singularLower($name)); - - $this->assertEquals('formInputField', StringCaser::camel($name)); - $this->assertEquals('form-input-field', StringCaser::kebab($name)); - $this->assertEquals('form_input_field', StringCaser::snake($name)); - $this->assertEquals('FormInputField', StringCaser::studly($name)); - $this->assertEquals('Form Input Field', StringCaser::title($name)); - $this->assertEquals('form input field', StringCaser::lower($name)); - } - - -} diff --git a/tests/Unit/StubRendererTest.php b/tests/Unit/StubRendererTest.php deleted file mode 100644 index 374d436..0000000 --- a/tests/Unit/StubRendererTest.php +++ /dev/null @@ -1,100 +0,0 @@ -getRenderer(); - - $expected_content = $this->getTestStubContents('SingleNameCases.php'); - $actual_content = $renderer->replaceFileNames($this->getTestStubPath('NameCases.stub'), 'category'); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_replace_multiple_names(): void - { - $renderer = $this->getRenderer(); - - $expected_content = $this->getTestStubContents('MultipleNameCases.php'); - $actual_content = $renderer->replaceFileNames($this->getTestStubPath('NameCases.stub'), 'form_input_field'); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_replace_multiple_names_with_suffix(): void - { - $renderer = $this->getRenderer(); - - $expected_content = $this->getTestStubContents('MultipleNameCases.php'); - $actual_content = $renderer->replaceFileNames($this->getTestStubPath('NameCasesSuffix.stub'), 'form_input_field', 'Model'); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_replace_multiple_names_in_studly_format(): void - { - $renderer = $this->getRenderer(); - - $expected_content = $this->getTestStubContents('MultipleNameCases.php'); - $actual_content = $renderer->replaceFileNames($this->getTestStubPath('NameCases.stub'), 'FormInputField'); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_replace_multiple_names_in_camel_format(): void - { - $renderer = $this->getRenderer(); - - $expected_content = $this->getTestStubContents('MultipleNameCases.php'); - $actual_content = $renderer->replaceFileNames($this->getTestStubPath('NameCases.stub'), 'formInputField'); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_append_to_a_stub_without_removing_marker(): void - { - $renderer = $this->getRenderer(); - - $expected_content = $this->getTestStubContents('AfterAppend.php'); - $actual_content = $renderer->appendToFile($this->getTestStubPath('BeforeAppend.php'), " 'product',\n", "// append here - DONOT REMOVE\n"); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_append_to_a_stub_with_removing_marker(): void - { - $renderer = $this->getRenderer(); - - $expected_content = $this->getTestStubContents('AfterAppendMarkerRemoved.php'); - $actual_content = $renderer->appendToFile($this->getTestStubPath('BeforeAppend.php'), "'product',\n", "// append here - DONOT REMOVE\n", false); - - $this->assertEquals($expected_content, $actual_content); - } - - public function test_it_can_add_indentation_to_multiple_lines(): void - { - $renderer = $this->getRenderer(); - - $expected_content = $this->getTestStubContents('Controllers/_onSaleBooleanIndented.stub'); - $actual_content = $renderer->addIndentation($this->getTestStubContents('Controllers/_onSaleBoolean.stub'), 1); - - $this->assertEquals($expected_content, $actual_content); - - $expected_content = $this->getTestStubContents('Controllers/_onSaleBoolean.stub'); - $actual_content = $renderer->addIndentation($this->getTestStubContents('Controllers/_onSaleBoolean.stub'), 0); - - $this->assertEquals($expected_content, $actual_content); - } - - protected function getRenderer(): StubRenderer - { - return $this->app->make(StubRenderer::class); - } - -} diff --git a/tests/Unit/Support/ImportsTest.php b/tests/Unit/Support/ImportsTest.php new file mode 100644 index 0000000..86cc687 --- /dev/null +++ b/tests/Unit/Support/ImportsTest.php @@ -0,0 +1,36 @@ +assertEquals('Model', $imports->add('Illuminate\\Database\\Eloquent\\Model')); + $this->assertEquals('Category', $imports->add('App\\Models\\Category')); + $this->assertEquals('BelongsTo', $imports->add('\\Illuminate\\Database\\Eloquent\\Relations\\BelongsTo')); + + // classes of the namespace itself need no import + $this->assertEquals( + "use Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;", + $imports->render() + ); + } + + #[Test] + public function it_uses_the_qualified_name_for_a_clashing_basename(): void + { + $imports = new Imports; + + $this->assertEquals('Form', $imports->add('Javaabu\\Stencil\\Form')); + $this->assertEquals('\\App\\Stencil\\Form', $imports->add('App\\Stencil\\Form')); + $this->assertTrue($imports->has('Javaabu\\Stencil\\Form')); + } +} diff --git a/tests/Unit/Support/NamingTest.php b/tests/Unit/Support/NamingTest.php new file mode 100644 index 0000000..172dffc --- /dev/null +++ b/tests/Unit/Support/NamingTest.php @@ -0,0 +1,61 @@ +assertEquals('category', Naming::belongsTo('category_id', 'categories')); + $this->assertEquals('blogPost', Naming::belongsTo('blog_post_id', 'blog_posts')); + $this->assertEquals('product', Naming::belongsTo('product_slug', 'products')); + $this->assertEquals('owner', Naming::belongsTo('owner', 'users')); + } + + #[Test] + public function it_parses_media_collections(): void + { + $collections = MediaCollection::parse('featured_image, gallery:image:multiple,documents:document:multiple,video:video'); + + $this->assertCount(4, $collections); + $this->assertEquals(['featured_image', 'image', false], [$collections[0]->name, $collections[0]->type, $collections[0]->multiple]); + $this->assertEquals(['gallery', 'image', true], [$collections[1]->name, $collections[1]->type, $collections[1]->multiple]); + $this->assertEquals(['documents', 'document', true], [$collections[2]->name, $collections[2]->type, $collections[2]->multiple]); + $this->assertEquals('Featured image', $collections[0]->label()); + } + + #[Test] + public function it_rejects_unknown_media_types(): void + { + $this->expectException(InvalidArgumentException::class); + + MediaCollection::parse('cover:picture'); + } + + #[Test] + public function it_reads_resource_options(): void + { + $options = ResourceOptions::fromArray([ + 'columns' => 'name, price', + 'index' => 'name', + 'form' => null, + 'media' => 'cover', + 'translatable' => 'title,body', + ]); + + $this->assertEquals(['name', 'price'], $options->columns); + $this->assertEquals(['name'], $options->index); + $this->assertNull($options->form); + $this->assertCount(1, $options->media); + $this->assertEquals(['title', 'body'], $options->translatable); + $this->assertTrue(ResourceOptions::fromArray(['translatable' => 'auto'])->translatable); + } +} diff --git a/tests/Unit/Support/PhpFileTest.php b/tests/Unit/Support/PhpFileTest.php new file mode 100644 index 0000000..c9f3201 --- /dev/null +++ b/tests/Unit/Support/PhpFileTest.php @@ -0,0 +1,78 @@ +provider, 'Javaabu\\Permissions\\Permissions'); + $code = PhpFile::addImport($code, 'Javaabu\\Permissions\\Permissions'); + $code = PhpFile::addImport($code, 'App\\Models\\Product'); + + $this->assertStringContainsString("use App\\Models\\Product;\nuse Illuminate\\Support\\ServiceProvider;\nuse Javaabu\\Permissions\\Permissions;\n", $code); + $this->assertEquals(1, substr_count($code, 'use Javaabu\\Permissions\\Permissions;')); + } + + #[Test] + public function it_appends_to_a_method_once(): void + { + $code = PhpFile::appendToMethod($this->provider, 'boot', "Permissions::resource('products');"); + $code = PhpFile::appendToMethod($code, 'boot', "Permissions::resource('products');"); + $code = PhpFile::appendToMethod($code, 'boot', "Permissions::resource('tags');"); + + $this->assertStringContainsString( + " //\n\n Permissions::resource('products');\n\n Permissions::resource('tags');\n }", + $code + ); + } + + #[Test] + public function it_fills_an_empty_method(): void + { + $code = PhpFile::appendToMethod("assertStringContainsString(" {\n one();\n }", $code); + } + + #[Test] + public function it_throws_when_the_method_is_missing(): void + { + $this->expectException(RuntimeException::class); + + PhpFile::appendToMethod($this->provider, 'register', 'one();'); + } + + #[Test] + public function it_inserts_before_a_marker_with_its_indentation(): void + { + $code = "group(function () {\n // marker\n});"; + + $code = PhpFile::insertBeforeMarker($code, '// marker', "one();\ntwo();"); + $code = PhpFile::insertBeforeMarker($code, '// marker', "one();\ntwo();"); + + $this->assertEquals("group(function () {\n one();\n two();\n\n // marker\n});", $code); + } +} diff --git a/tests/Unit/Support/StubTest.php b/tests/Unit/Support/StubTest.php new file mode 100644 index 0000000..70a2036 --- /dev/null +++ b/tests/Unit/Support/StubTest.php @@ -0,0 +1,39 @@ +assertEquals("class A\n{\n one();\n\n two();\n}", Stub::fill($template, ['body' => "one();\n\ntwo();"])); + } + + #[Test] + public function it_removes_the_line_of_an_empty_block(): void + { + $template = "class A\n{\n {{ traits }}\n\n {{ body }}\n}"; + + $this->assertEquals("class A\n{\n body();\n}", Stub::fill($template, ['traits' => '', 'body' => 'body();'])); + } + + #[Test] + public function it_replaces_inline_placeholders_and_keeps_unknown_ones(): void + { + $this->assertEquals('Product {{ other }} {{ $blade }}', Stub::fill('{{ name }} {{ other }} {{ $blade }}', ['name' => 'Product'])); + } + + #[Test] + public function it_indents_code(): void + { + $this->assertEquals(" a\n\n b", Stub::indent("a\n\nb")); + $this->assertEquals(' a', Stub::indent('a', 2)); + } +} diff --git a/tests/snapshots/categories.blade.txt b/tests/snapshots/categories.blade.txt new file mode 100644 index 0000000..d13cf65 --- /dev/null +++ b/tests/snapshots/categories.blade.txt @@ -0,0 +1,238 @@ +// app/Models/Category.php + */ + use HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'slug', + ]; + + /** + * @return HasMany + */ + public function allForeigns(): HasMany + { + return $this->hasMany(AllForeign::class); + } + + /** + * @return HasMany + */ + public function customers(): HasMany + { + return $this->hasMany(Customer::class); + } + + /** + * @return HasMany + */ + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + /** + * @return HasMany + */ + public function products(): HasMany + { + return $this->hasMany(Product::class); + } + + /** + * @return HasMany + */ + public function singleValueEnumOrders(): HasMany + { + return $this->hasMany(SingleValueEnumOrder::class); + } +} + +// app/Filters/CategoryFilters.php +label(__('Search'))->columns(['name', 'slug']), + DateRangeFilter::make('created_at')->label(__('Created at')), + ]; + } + + protected function sorts(): array + { + return [ + Sort::make('name')->label(__('Name')), + Sort::make('slug')->label(__('Slug')), + Sort::make('created_at')->label(__('Created at')), + Sort::make('updated_at')->label(__('Updated at')), + ]; + } +} + +// resources/views/admin/categories/index.blade.php +@extends('layouts.admin') + +@section('title', __('Categories')) + +@section('content') +
+
+

{{ __('Categories') }}

+
+ + @can('create', \App\Models\Category::class) + + {{ __('New category') }} + + @endcan +
+ + + + + + + + + + + + + + + @forelse($categories as $category) + + + @can('view', $category) + {{ $category->name }} + @else + {{ $category->name }} + @endcan + + + + @empty + + {{ $filters->hasActiveFilters() ? __('No categories match these filters.') : __('No categories yet.') }} + + @endforelse + + + @if($categories->hasPages()) + + + + @endif + +@endsection + +// resources/views/admin/categories/create.blade.php +@extends('layouts.admin') + +@section('title', __('New category')) + +@section('content') +
+ +

{{ __('New category') }}

+
+ + {{ $form }} +@endsection + +// resources/views/admin/categories/edit.blade.php +@extends('layouts.admin') + +@section('title', __('Edit category')) + +@section('content') +
+
+ +

{{ __('Edit category') }}

+
+ + + {{ __('View') }} + +
+ + {{ $form }} +@endsection + +// resources/views/admin/categories/show.blade.php +@extends('layouts.admin') + +@section('title', $category->name) + +@section('content') +
+
+ +

{{ $category->name }}

+
+ +
+ @can('update', $category) + + {{ __('Edit') }} + + @endcan + + @can('delete', $category) +
+ @csrf + @method('DELETE') + + + {{ __('Delete') }} + +
+ @endcan +
+
+ + + + + +@endsection diff --git a/tests/snapshots/orders.enum-class-and-a-key-to-a-slug.txt b/tests/snapshots/orders.enum-class-and-a-key-to-a-slug.txt new file mode 100644 index 0000000..35f1f65 --- /dev/null +++ b/tests/snapshots/orders.enum-class-and-a-key-to-a-slug.txt @@ -0,0 +1,382 @@ +// app/Models/Order.php + */ + use HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'order_no', + 'category_id', + 'product_slug', + 'status', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => OrderStatuses::class, + ]; + } + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class, 'product_slug', 'slug'); + } + + /** + * @return BelongsTo + */ + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + /** + * @return HasMany + */ + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } +} + +// database/factories/OrderFactory.php + + */ +class OrderFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = Order::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'order_no' => fake()->text(50), + 'category_id' => Category::factory(), + 'product_slug' => fn () => Product::factory()->create()->slug, + 'status' => fake()->randomElement(OrderStatuses::cases()), + ]; + } +} + +// app/Stencil/OrderForm.php +columns(2) + ->schema([ + TextInput::make('order_no') + ->label(__('Order no')) + ->required() + ->maxLength(255), + Select::make('category_id') + ->label(__('Category')) + ->required() + ->relationship('category', 'name') + ->searchable(), + Select::make('product_slug') + ->label(__('Product')) + ->required() + ->relationship('product', 'name') + ->searchable(), + Select::make('status') + ->label(__('Status')) + ->required() + ->options(OrderStatuses::class), + ]), + ]; + } +} + +// tests/Feature/Admin/OrdersControllerTest.php +artisan('permissions:sync'); + } + + #[Test] + public function it_lists_the_orders(): void + { + $order = Order::factory()->create(); + + $this->actingAsAdmin('view_orders'); + + $this->get(route('admin.orders.index')) + ->assertOk() + ->assertSee($order->order_no); + } + + #[Test] + public function it_does_not_list_the_orders_without_permission(): void + { + $this->actingAsAdmin(); + + $this->get(route('admin.orders.index')) + ->assertForbidden(); + } + + #[Test] + public function it_searches_the_orders(): void + { + $order = Order::factory()->create(['order_no' => 'ރަސްމީ ދުވަސް']); + $other = Order::factory()->create(['order_no' => 'Something else']); + + $this->actingAsAdmin('view_orders'); + + $this->get(route('admin.orders.index', ['search' => 'ރަސްމީ'])) + ->assertOk() + ->assertSee('ރަސްމީ ދުވަސް') + ->assertDontSee('Something else'); + } + + #[Test] + public function it_shows_the_new_order_form(): void + { + $this->actingAsAdmin('edit_orders'); + + $this->get(route('admin.orders.create')) + ->assertOk() + ->assertSee(__('New order')); + } + + #[Test] + public function it_creates_a_order(): void + { + $category = Category::factory()->create(); + $other_category = Category::factory()->create(); + $product = Product::factory()->create(); + $other_product = Product::factory()->create(); + + $this->actingAsAdmin('edit_orders'); + + $this->post(route('admin.orders.store'), [ + 'order_no' => 'ރަސްމީ', + 'category_id' => $category->id, + 'product_slug' => $product->slug, + 'status' => OrderStatuses::Pending->value, + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); + + $this->assertDatabaseHas('orders', [ + 'order_no' => 'ރަސްމީ', + 'category_id' => $category->id, + 'product_slug' => $product->slug, + 'status' => OrderStatuses::Pending->value, + ]); + } + + #[Test] + public function it_validates_new_orders(): void + { + $this->actingAsAdmin('edit_orders'); + + $this->post(route('admin.orders.store'), [ + 'order_no' => [], + 'category_id' => -1, + 'product_slug' => 'ބަދަލު', + 'status' => 'ބަދަލު', + ]) + ->assertSessionHasErrors(['order_no', 'category_id', 'product_slug', 'status']); + + $this->assertDatabaseCount('orders', 0); + } + + #[Test] + public function it_does_not_create_orders_without_permission(): void + { + $this->actingAsAdmin('view_orders'); + + $this->post(route('admin.orders.store'), []) + ->assertForbidden(); + + $this->assertDatabaseCount('orders', 0); + } + + #[Test] + public function it_shows_a_order(): void + { + $order = Order::factory()->create(); + + $this->actingAsAdmin('view_orders'); + + $this->get(route('admin.orders.show', $order)) + ->assertOk() + ->assertSee($order->order_no); + } + + #[Test] + public function it_shows_the_edit_order_form(): void + { + $order = Order::factory()->create(); + + $this->actingAsAdmin('edit_orders'); + + $this->get(route('admin.orders.edit', $order)) + ->assertOk() + ->assertSee(__('Edit order')); + } + + #[Test] + public function it_updates_a_order(): void + { + $category = Category::factory()->create(); + $other_category = Category::factory()->create(); + $product = Product::factory()->create(); + $other_product = Product::factory()->create(); + + $order = Order::factory()->create(); + + $this->actingAsAdmin('edit_orders'); + + $this->patch(route('admin.orders.update', $order), [ + 'order_no' => 'ދިވެހި', + 'category_id' => $other_category->id, + 'product_slug' => $other_product->slug, + 'status' => OrderStatuses::Paid->value, + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); + + $this->assertDatabaseHas('orders', [ + 'id' => $order->id, + 'order_no' => 'ދިވެހި', + 'category_id' => $other_category->id, + 'product_slug' => $other_product->slug, + 'status' => OrderStatuses::Paid->value, + ]); + } + + #[Test] + public function it_does_not_update_orders_without_permission(): void + { + $order = Order::factory()->create(); + + $this->actingAsAdmin('view_orders'); + + $this->patch(route('admin.orders.update', $order), []) + ->assertForbidden(); + } + + #[Test] + public function it_deletes_a_order(): void + { + $order = Order::factory()->create(); + + $this->actingAsAdmin('delete_orders'); + + $this->delete(route('admin.orders.destroy', $order)) + ->assertRedirect(route('admin.orders.index')); + + $this->assertModelMissing($order); + } + + #[Test] + public function it_deletes_orders_in_bulk(): void + { + $orders = Order::factory()->count(2)->create(); + + $this->actingAsAdmin('view_orders', 'delete_orders'); + + $this->postJson(route('admin.orders.bulk'), [ + 'action' => 'delete', + 'orders' => $orders->pluck('id')->all(), + ]) + ->assertOk() + ->assertJson(['succeeded' => 2]); + + $orders->each(fn (Order $record) => $this->assertModelMissing($record)); + } + + /** + * Log in as a user of the admin area with these permissions. + */ + protected function actingAsAdmin(string ...$permissions): User + { + $user = User::factory()->create(); + + if ($permissions) { + $user->givePermissionTo($permissions); + } + + $this->actingAs($user); + + return $user; + } +} diff --git a/tests/snapshots/posts-with-media.blade.txt b/tests/snapshots/posts-with-media.blade.txt new file mode 100644 index 0000000..91d8024 --- /dev/null +++ b/tests/snapshots/posts-with-media.blade.txt @@ -0,0 +1,286 @@ +// app/Models/Post.php + */ + use HasFactory; + + use InteractsWithAttachments; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'title', + 'published_at', + 'excerpt', + 'content', + 'medium_content', + 'tiny_content', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'published_at' => 'datetime', + ]; + } + + /** + * The media collections, picked from the media library. + */ + public function registerAttachmentCollections(): void + { + $this->addAttachmentCollection('featured_image') + ->singleFile() + ->acceptsFile(fn (File $file) => FileTypes::isAllowed($file->mimeType, 'image')); + + $this->addAttachmentCollection('gallery') + ->acceptsFile(fn (File $file) => FileTypes::isAllowed($file->mimeType, 'image')); + + $this->addAttachmentCollection('attachments') + ->acceptsFile(fn (File $file) => FileTypes::isAllowed($file->mimeType, 'document')); + } +} + +// app/Stencil/PostForm.php +columns(2) + ->schema([ + TextInput::make('title') + ->label(__('Title')) + ->required() + ->maxLength(255), + DateTimePicker::make('published_at')->label(__('Published at')), + Textarea::make('excerpt') + ->label(__('Excerpt')) + ->required() + ->rows(4) + ->maxLength(65535) + ->columnSpanFull(), + Textarea::make('content') + ->label(__('Content')) + ->rows(4) + ->maxLength(65535) + ->columnSpanFull(), + Textarea::make('medium_content') + ->label(__('Medium content')) + ->rows(4) + ->maxLength(65535) + ->columnSpanFull(), + Textarea::make('tiny_content') + ->label(__('Tiny content')) + ->rows(4) + ->maxLength(65535) + ->columnSpanFull(), + ]), + Section::make(__('Media')) + ->schema([ + MediaPickerField::make('featured_image') + ->label(__('Featured image')) + ->mediaType('image'), + MediaPickerField::make('gallery') + ->label(__('Gallery')) + ->mediaType('image') + ->multiple(), + MediaPickerField::make('attachments') + ->label(__('Attachments')) + ->mediaType('document') + ->multiple(), + ]), + ]; + } +} + +// resources/views/admin/posts/index.blade.php +@extends('layouts.admin') + +@section('title', __('Posts')) + +@section('content') +
+
+

{{ __('Posts') }}

+
+ + @can('create', \App\Models\Post::class) + + {{ __('New post') }} + + @endcan +
+ + + + + + + + + + + + + + + @forelse($posts as $post) + + + @can('view', $post) + {{ $post->title }} + @else + {{ $post->title }} + @endcan + + + + @empty + + {{ $filters->hasActiveFilters() ? __('No posts match these filters.') : __('No posts yet.') }} + + @endforelse + + + @if($posts->hasPages()) + + + + @endif + +@endsection + +// resources/views/admin/posts/create.blade.php +@extends('layouts.admin') + +@section('title', __('New post')) + +@section('content') +
+ +

{{ __('New post') }}

+
+ + {{ $form }} +@endsection + +// resources/views/admin/posts/edit.blade.php +@extends('layouts.admin') + +@section('title', __('Edit post')) + +@section('content') +
+
+ +

{{ __('Edit post') }}

+
+ + + {{ __('View') }} + +
+ + {{ $form }} +@endsection + +// resources/views/admin/posts/show.blade.php +@extends('layouts.admin') + +@section('title', $post->title) + +@section('content') +
+
+ +

{{ $post->title }}

+
+ +
+ @can('update', $post) + + {{ __('Edit') }} + + @endcan + + @can('delete', $post) +
+ @csrf + @method('DELETE') + + + {{ __('Delete') }} + +
+ @endcan +
+
+ + + + + + + + + + + + + + + +
+ @foreach($post->getAttachments('gallery') as $attachment) + + @endforeach +
+
+ + +
    + @foreach($post->getAttachmentMedia('attachments') as $media) +
  • {{ $media->name }}
  • + @endforeach +
+
+@endsection diff --git a/tests/snapshots/posts-with-media.inertia.txt b/tests/snapshots/posts-with-media.inertia.txt new file mode 100644 index 0000000..22740f9 --- /dev/null +++ b/tests/snapshots/posts-with-media.inertia.txt @@ -0,0 +1,264 @@ +// app/Http/Resources/Admin/PostResource.php + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'published_at' => $this->published_at?->format('Y-m-d H:i'), + 'excerpt' => $this->excerpt, + 'content' => $this->content, + 'medium_content' => $this->medium_content, + 'tiny_content' => $this->tiny_content, + 'featured_image' => $this->getFirstAttachmentUrl('featured_image') ?: null, + 'attachments' => $this->getAttachmentMedia('attachments')->map(fn ($media) => $media->getUrl())->values()->all(), + 'created_at' => $this->created_at?->format('Y-m-d H:i'), + 'updated_at' => $this->updated_at?->format('Y-m-d H:i'), + 'urls' => [ + 'show' => route('admin.posts.show', $this->resource), + 'edit' => route('admin.posts.edit', $this->resource), + 'destroy' => route('admin.posts.destroy', $this->resource), + ], + 'can' => [ + 'view' => (bool) $request->user()?->can('view', $this->resource), + 'update' => (bool) $request->user()?->can('update', $this->resource), + 'delete' => (bool) $request->user()?->can('delete', $this->resource), + ], + ]; + } +} + +// resources/js/pages/admin/posts/types.ts +/** A post, as PostResource sends it. */ +export interface Post { + id: number + title: string + published_at: string | null + excerpt: string + content: string | null + medium_content: string | null + tiny_content: string | null + featured_image: string | null + attachments: string[] + created_at: string | null + updated_at: string | null + urls: { show: string; edit: string; destroy: string } + can: { view: boolean; update: boolean; delete: boolean } +} + +// resources/js/pages/admin/posts/Index.tsx +import { Head, Link, router } from "@inertiajs/react" +import { BulkActionsTable, type BulkActionsSchema } from "@javaabu/bulk-actions" +import { FilterBar, SortableHeading, type FiltersData } from "@javaabu/filters" +import { type LaravelResourceCollection, LinkButton, Pagination, TableCell, TableEmptyRow, TableHeading, TableRow } from "@javaabu/forms" +import { __ } from "@javaabu/i18n" +import type { Post } from "./types" + +interface Props { + posts: Omit & { data: Post[] } + filters: FiltersData + bulkActions: BulkActionsSchema + createUrl: string | null +} + +export default function Index({ posts, filters, bulkActions, createUrl }: Props) { + return ( + <> + + +
+
+

{__("Posts")}

+
+ + {createUrl && ( + + {__("New post")} + + )} +
+ + + + router.reload()} + headers={ + <> + + + + } + pagination={posts.meta.last_page > 1 ? : null} + > + {posts.data.length > 0 ? ( + posts.data.map((post) => ( + + + {post.can.view ? ( + + {post.title} + + ) : ( + post.title + )} + + + + )) + ) : ( + + {filters.active > 0 + ? __("No posts match these filters.") + : __("No posts yet.")} + + )} + + + ) +} + +// resources/js/pages/admin/posts/Create.tsx +import { Head } from "@inertiajs/react" +import { __ } from "@javaabu/i18n" +import { StencilMediaPicker } from "@javaabu/mediapicker" +import { StencilForm, type StencilSchema } from "@javaabu/stencil" + +interface Props { + form: StencilSchema +} + +export default function Create({ form }: Props) { + return ( + <> + + +
+
+

{__("New post")}

+
+
+ + + + ) +} + +// resources/js/pages/admin/posts/Edit.tsx +import { Head } from "@inertiajs/react" +import { LinkButton } from "@javaabu/forms" +import { __ } from "@javaabu/i18n" +import { StencilMediaPicker } from "@javaabu/mediapicker" +import { StencilForm, type StencilSchema } from "@javaabu/stencil" +import type { Post } from "./types" + +interface Props { + post: Post + form: StencilSchema +} + +export default function Edit({ post, form }: Props) { + return ( + <> + + +
+
+

{__("Edit post")}

+
+ + + {__("View")} + +
+ + + + ) +} + +// resources/js/pages/admin/posts/Show.tsx +import { Head, router } from "@inertiajs/react" +import { Button, Card, Infolist, LinkButton, TextEntry } from "@javaabu/forms" +import { __ } from "@javaabu/i18n" +import type { Post } from "./types" + +interface Props { + post: Post +} + +export default function Show({ post }: Props) { + return ( + <> + + +
+
+

{post.title}

+
+ +
+ {post.can.update && ( + + {__("Edit")} + + )} + {post.can.delete && ( + + )} +
+
+ + + + + + + + + + + + {post.featured_image && } + + + + + + + ) +} diff --git a/tests/snapshots/products-nested-in-categories.blade.txt b/tests/snapshots/products-nested-in-categories.blade.txt new file mode 100644 index 0000000..415a194 --- /dev/null +++ b/tests/snapshots/products-nested-in-categories.blade.txt @@ -0,0 +1,670 @@ +// app/Http/Controllers/Admin/ProductsController.php +apply(Product::query()->whereBelongsTo($category, 'category')) + ->paginate($request->integer('per_page', 20)) + ->withQueryString(); + + return view('admin.products.index', [ + 'category' => $category, + 'products' => $products, + 'filters' => $filters, + 'bulk_actions' => ProductBulkActions::make()->paginator($products), + ]); + } + + /** + * Show the form for a new product. + */ + public function create(Category $category): View + { + Gate::authorize('create', Product::class); + + return view('admin.products.create', [ + 'category' => $category, + 'form' => ProductForm::make()->model(Product::class)->action(route('admin.categories.products.store', $category)), + ]); + } + + /** + * Save a new product. + */ + public function store(Category $category): RedirectResponse + { + Gate::authorize('create', Product::class); + + $product = ProductForm::make()->model((new Product)->category()->associate($category))->save(); + + return redirect(route('admin.categories.products.show', [$category, $product])) + ->with('status', __('Product created.')); + } + + /** + * Show a product. + */ + public function show(Category $category, Product $product): View + { + Gate::authorize('view', $product); + + $product->load(['tags']); + + return view('admin.products.show', [ + 'category' => $category, + 'product' => $product, + ]); + } + + /** + * Show the form for editing a product. + */ + public function edit(Category $category, Product $product): View + { + Gate::authorize('update', $product); + + return view('admin.products.edit', [ + 'category' => $category, + 'product' => $product, + 'form' => ProductForm::make()->model($product)->action(route('admin.categories.products.update', [$category, $product])), + ]); + } + + /** + * Save the changes to a product. + */ + public function update(Category $category, Product $product): RedirectResponse + { + Gate::authorize('update', $product); + + ProductForm::make()->model($product)->save(); + + return redirect(route('admin.categories.products.show', [$category, $product])) + ->with('status', __('Product updated.')); + } + + /** + * Delete a product. + */ + public function destroy(Category $category, Product $product): RedirectResponse + { + Gate::authorize('delete', $product); + + $product->delete(); + + return redirect(route('admin.categories.products.index', $category)) + ->with('status', __('Product deleted.')); + } + + /** + * Restore a deleted product. + */ + public function restore(Category $category, Product $product): RedirectResponse + { + Gate::authorize('restore', $product); + + $product->restore(); + + return redirect(route('admin.categories.products.show', [$category, $product])) + ->with('status', __('Product restored.')); + } + + /** + * Permanently delete a product. + */ + public function forceDelete(Category $category, Product $product): RedirectResponse + { + Gate::authorize('forceDelete', $product); + + $product->forceDelete(); + + return redirect(route('admin.categories.products.index', $category)) + ->with('status', __('Product permanently deleted.')); + } +} + +// app/BulkActions/ProductBulkActions.php +filters(ProductFilters::class); + + // only the products of the category in the URL + $this->query(fn () => Product::query()->whereBelongsTo($this->category(), 'category')); + $this->url(fn () => route('admin.categories.products.bulk', $this->category())); + } + + protected function actions(): array + { + return [ + DeleteAction::make()->can('delete_products'), + RestoreAction::make()->can('delete_products'), + ForceDeleteAction::make()->can('force_delete_products'), + ]; + } + + /** + * The category in the URL. + */ + protected function category(): Category + { + $category = request()->route('category'); + + if ($category instanceof Category) { + return $category; + } + + return Category::query()->where('id', $category)->firstOrFail(); + } +} + +// routes/admin.php +// Products +Route::resource('categories.products', ProductsController::class)->scoped()->withTrashed(['show']); +Route::patch('categories/{category}/products/{product}/restore', [ProductsController::class, 'restore'])->scopeBindings()->withTrashed()->name('categories.products.restore'); +Route::delete('categories/{category}/products/{product}/force-delete', [ProductsController::class, 'forceDelete'])->scopeBindings()->withTrashed()->name('categories.products.force-delete'); +Route::bulkActions('categories/{category}/products', ProductBulkActions::class, ['name' => 'categories.products.bulk']); +// routes/web.php +require __DIR__ . '/admin.php'; +// resources/views/admin/products/index.blade.php +@extends('layouts.admin') + +@section('title', __('Products')) + +@section('content') +
+
+ +

{{ __('Products') }}

+
+ + @can('create', \App\Models\Product::class) + + {{ __('New product') }} + + @endcan +
+ + + + + + + + + + + + + + + @forelse($products as $product) + + + @can('view', $product) + {{ $product->name }} + @else + {{ $product->name }} + @endcan + + + + @empty + + {{ $filters->hasActiveFilters() ? __('No products match these filters.') : __('No products yet.') }} + + @endforelse + + + @if($products->hasPages()) + + + + @endif + +@endsection + +// resources/views/admin/products/create.blade.php +@extends('layouts.admin') + +@section('title', __('New product')) + +@section('content') +
+ +

{{ __('New product') }}

+
+ + {{ $form }} +@endsection + +// resources/views/admin/products/edit.blade.php +@extends('layouts.admin') + +@section('title', __('Edit product')) + +@section('content') +
+
+ +

{{ __('Edit product') }}

+
+ + + {{ __('View') }} + +
+ + {{ $form }} +@endsection + +// resources/views/admin/products/show.blade.php +@extends('layouts.admin') + +@section('title', $product->name) + +@section('content') +
+
+ +

{{ $product->name }}

+
+ +
+ @if($product->trashed()) + @can('restore', $product) +
+ @csrf + @method('PATCH') + + + {{ __('Restore') }} + +
+ @endcan + + @can('forceDelete', $product) +
+ @csrf + @method('DELETE') + + + {{ __('Delete permanently') }} + +
+ @endcan + @else + @can('update', $product) + + {{ __('Edit') }} + + @endcan + + @can('delete', $product) +
+ @csrf + @method('DELETE') + + + {{ __('Delete') }} + +
+ @endcan + @endif +
+
+ + @if($product->trashed()) + + {{ __('This product is deleted.') }} + + @endif + + + + + + + + + + + + + + + + + + + +@endsection + +// tests/Feature/Admin/ProductsControllerTest.php +artisan('permissions:sync'); + } + + #[Test] + public function it_lists_the_products(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(); + + $this->actingAsAdmin('view_products'); + + $this->get(route('admin.categories.products.index', $category)) + ->assertOk() + ->assertSee($product->name); + } + + #[Test] + public function it_does_not_list_the_products_without_permission(): void + { + $category = Category::factory()->create(); + $this->actingAsAdmin(); + + $this->get(route('admin.categories.products.index', $category)) + ->assertForbidden(); + } + + #[Test] + public function it_searches_the_products(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(['name' => 'ރަސްމީ ދުވަސް']); + $other = Product::factory()->for($category, 'category')->create(['name' => 'Something else']); + + $this->actingAsAdmin('view_products'); + + $this->get(route('admin.categories.products.index', [$category, 'search' => 'ރަސްމީ'])) + ->assertOk() + ->assertSee('ރަސްމީ ދުވަސް') + ->assertDontSee('Something else'); + } + + #[Test] + public function it_shows_the_new_product_form(): void + { + $category = Category::factory()->create(); + $this->actingAsAdmin('edit_products'); + + $this->get(route('admin.categories.products.create', $category)) + ->assertOk() + ->assertSee(__('New product')); + } + + #[Test] + public function it_creates_a_product(): void + { + $category = Category::factory()->create(); + $tags = Tag::factory()->count(2)->create(); + + $this->actingAsAdmin('edit_products'); + + $this->post(route('admin.categories.products.store', $category), [ + 'name' => 'ރަސްމީ', + 'price' => 10.5, + 'status' => 'draft', + 'tags' => $tags->pluck('id')->all(), + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); + + $this->assertDatabaseHas('products', [ + 'category_id' => $category->id, + 'name' => 'ރަސްމީ', + 'price' => 10.5, + 'status' => 'draft', + ]); + + $product = Product::query()->latest('id')->first(); + + $this->assertEquals($tags->pluck('id')->all(), $product->tags()->pluck('tags.id')->sort()->values()->all()); + } + + #[Test] + public function it_validates_new_products(): void + { + $category = Category::factory()->create(); + $this->actingAsAdmin('edit_products'); + + $this->post(route('admin.categories.products.store', $category), [ + 'name' => [], + 'price' => 'ބަދަލު', + 'status' => 'ބަދަލު', + ]) + ->assertSessionHasErrors(['name', 'price', 'status']); + + $this->assertDatabaseCount('products', 0); + } + + #[Test] + public function it_does_not_create_products_without_permission(): void + { + $category = Category::factory()->create(); + $this->actingAsAdmin('view_products'); + + $this->post(route('admin.categories.products.store', $category), []) + ->assertForbidden(); + + $this->assertDatabaseCount('products', 0); + } + + #[Test] + public function it_shows_a_product(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(); + + $this->actingAsAdmin('view_products'); + + $this->get(route('admin.categories.products.show', [$category, $product])) + ->assertOk() + ->assertSee($product->name); + } + + #[Test] + public function it_shows_the_edit_product_form(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(); + + $this->actingAsAdmin('edit_products'); + + $this->get(route('admin.categories.products.edit', [$category, $product])) + ->assertOk() + ->assertSee(__('Edit product')); + } + + #[Test] + public function it_updates_a_product(): void + { + $category = Category::factory()->create(); + $tags = Tag::factory()->count(2)->create(); + + $product = Product::factory()->for($category, 'category')->create(); + + $this->actingAsAdmin('edit_products'); + + $this->patch(route('admin.categories.products.update', [$category, $product]), [ + 'name' => 'ދިވެހި', + 'price' => 20.25, + 'status' => 'published', + 'tags' => [$tags->first()->id], + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); + + $this->assertDatabaseHas('products', [ + 'id' => $product->id, + 'name' => 'ދިވެހި', + 'price' => 20.25, + 'status' => 'published', + ]); + + $product->refresh(); + + $this->assertEquals([$tags->first()->id], $product->tags()->pluck('tags.id')->sort()->values()->all()); + } + + #[Test] + public function it_does_not_update_products_without_permission(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(); + + $this->actingAsAdmin('view_products'); + + $this->patch(route('admin.categories.products.update', [$category, $product]), []) + ->assertForbidden(); + } + + #[Test] + public function it_deletes_a_product(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(); + + $this->actingAsAdmin('delete_products'); + + $this->delete(route('admin.categories.products.destroy', [$category, $product])) + ->assertRedirect(route('admin.categories.products.index', $category)); + + $this->assertSoftDeleted($product); + } + + #[Test] + public function it_restores_a_deleted_product(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(); + $product->delete(); + + $this->actingAsAdmin('delete_products'); + + $this->patch(route('admin.categories.products.restore', [$category, $product])) + ->assertRedirect(); + + $this->assertNotSoftDeleted($product); + } + + #[Test] + public function it_permanently_deletes_a_product(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(); + $product->delete(); + + $this->actingAsAdmin('force_delete_products'); + + $this->delete(route('admin.categories.products.force-delete', [$category, $product])) + ->assertRedirect(route('admin.categories.products.index', $category)); + + $this->assertModelMissing($product); + } + + #[Test] + public function it_deletes_products_in_bulk(): void + { + $category = Category::factory()->create(); + $products = Product::factory()->for($category, 'category')->count(2)->create(); + + $this->actingAsAdmin('view_products', 'delete_products'); + + $this->postJson(route('admin.categories.products.bulk', $category), [ + 'action' => 'delete', + 'products' => $products->pluck('id')->all(), + ]) + ->assertOk() + ->assertJson(['succeeded' => 2]); + + $products->each(fn (Product $record) => $this->assertSoftDeleted($record)); + } + + /** + * Log in as a user of the admin area with these permissions. + */ + protected function actingAsAdmin(string ...$permissions): User + { + $user = User::factory()->create(); + + if ($permissions) { + $user->givePermissionTo($permissions); + } + + $this->actingAs($user); + + return $user; + } +} diff --git a/tests/snapshots/products-nested-in-categories.inertia.txt b/tests/snapshots/products-nested-in-categories.inertia.txt new file mode 100644 index 0000000..a6d1306 --- /dev/null +++ b/tests/snapshots/products-nested-in-categories.inertia.txt @@ -0,0 +1,768 @@ +// app/Http/Controllers/Admin/ProductsController.php +apply(Product::query()->whereBelongsTo($category, 'category')) + ->paginate($request->integer('per_page', 20)) + ->withQueryString(); + + return Inertia::render('admin/products/Index', [ + 'category' => $category, + 'products' => ProductResource::collection($products), + 'filters' => $filters, + 'bulkActions' => ProductBulkActions::make()->paginator($products), + 'createUrl' => Gate::allows('create', Product::class) ? route('admin.categories.products.create', $category) : null, + ]); + } + + /** + * Show the form for a new product. + */ + public function create(Category $category): Response + { + Gate::authorize('create', Product::class); + + return Inertia::render('admin/products/Create', [ + 'category' => $category, + 'form' => ProductForm::make()->model(Product::class)->action(route('admin.categories.products.store', $category)), + ]); + } + + /** + * Save a new product. + */ + public function store(Category $category): RedirectResponse + { + Gate::authorize('create', Product::class); + + $product = ProductForm::make()->model((new Product)->category()->associate($category))->save(); + + return redirect(route('admin.categories.products.show', [$category, $product])) + ->with('status', __('Product created.')); + } + + /** + * Show a product. + */ + public function show(Category $category, Product $product): Response + { + Gate::authorize('view', $product); + + $product->load(['tags']); + + return Inertia::render('admin/products/Show', [ + 'category' => $category, + 'product' => ProductResource::make($product)->resolve(), + ]); + } + + /** + * Show the form for editing a product. + */ + public function edit(Category $category, Product $product): Response + { + Gate::authorize('update', $product); + + return Inertia::render('admin/products/Edit', [ + 'category' => $category, + 'product' => ProductResource::make($product)->resolve(), + 'form' => ProductForm::make()->model($product)->action(route('admin.categories.products.update', [$category, $product])), + ]); + } + + /** + * Save the changes to a product. + */ + public function update(Category $category, Product $product): RedirectResponse + { + Gate::authorize('update', $product); + + ProductForm::make()->model($product)->save(); + + return redirect(route('admin.categories.products.show', [$category, $product])) + ->with('status', __('Product updated.')); + } + + /** + * Delete a product. + */ + public function destroy(Category $category, Product $product): RedirectResponse + { + Gate::authorize('delete', $product); + + $product->delete(); + + return redirect(route('admin.categories.products.index', $category)) + ->with('status', __('Product deleted.')); + } + + /** + * Restore a deleted product. + */ + public function restore(Category $category, Product $product): RedirectResponse + { + Gate::authorize('restore', $product); + + $product->restore(); + + return redirect(route('admin.categories.products.show', [$category, $product])) + ->with('status', __('Product restored.')); + } + + /** + * Permanently delete a product. + */ + public function forceDelete(Category $category, Product $product): RedirectResponse + { + Gate::authorize('forceDelete', $product); + + $product->forceDelete(); + + return redirect(route('admin.categories.products.index', $category)) + ->with('status', __('Product permanently deleted.')); + } +} + +// app/Http/Resources/Admin/ProductResource.php + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'address' => $this->address, + 'slug' => $this->slug, + 'description' => $this->description, + 'price' => $this->price, + 'stock' => $this->stock, + 'on_sale' => $this->on_sale, + 'features' => $this->features, + 'published_at' => $this->published_at?->format('Y-m-d H:i'), + 'expire_at' => $this->expire_at?->format('Y-m-d H:i'), + 'released_on' => $this->released_on?->format('Y-m-d'), + 'sale_time' => $this->sale_time, + 'status' => $this->status, + 'category_id' => $this->category_id, + 'category' => $this->whenLoaded('category', fn () => [ + 'id' => $this->category->id, + 'name' => $this->category->name, + ]), + 'manufactured_year' => $this->manufactured_year, + 'tags' => $this->whenLoaded('tags', fn () => $this->tags->map(fn (Tag $tag) => [ + 'id' => $tag->getKey(), + 'name' => $tag->name, + ])->all()), + 'created_at' => $this->created_at?->format('Y-m-d H:i'), + 'updated_at' => $this->updated_at?->format('Y-m-d H:i'), + 'deleted_at' => $this->deleted_at?->format('Y-m-d H:i'), + 'urls' => [ + 'show' => route('admin.categories.products.show', [$this->category_id, $this->resource]), + 'edit' => route('admin.categories.products.edit', [$this->category_id, $this->resource]), + 'destroy' => route('admin.categories.products.destroy', [$this->category_id, $this->resource]), + 'restore' => route('admin.categories.products.restore', [$this->category_id, $this->resource]), + 'force_delete' => route('admin.categories.products.force-delete', [$this->category_id, $this->resource]), + ], + 'can' => [ + 'view' => (bool) $request->user()?->can('view', $this->resource), + 'update' => (bool) $request->user()?->can('update', $this->resource), + 'delete' => (bool) $request->user()?->can('delete', $this->resource), + 'restore' => (bool) $request->user()?->can('restore', $this->resource), + 'forceDelete' => (bool) $request->user()?->can('forceDelete', $this->resource), + ], + ]; + } +} + +// resources/js/pages/admin/products/types.ts +/** A product, as ProductResource sends it. */ +export interface Product { + id: number + name: string + address: string + slug: string + description: string | null + price: string + stock: number + on_sale: boolean + features: string + published_at: string + expire_at: string + released_on: string + sale_time: string + status: "draft" | "published" + category_id: number | null + category?: { id: number; name: string } | null + manufactured_year: number + tags?: { id: number; name: string }[] + created_at: string | null + updated_at: string | null + deleted_at: string | null + urls: { show: string; edit: string; destroy: string; restore: string; force_delete: string } + can: { view: boolean; update: boolean; delete: boolean; restore: boolean; forceDelete: boolean } +} + +/** The category the products belong to. */ +export interface Category { + id: number + name: string +} + +// resources/js/pages/admin/products/Index.tsx +import { Head, Link, router } from "@inertiajs/react" +import { BulkActionsTable, type BulkActionsSchema } from "@javaabu/bulk-actions" +import { FilterBar, SortableHeading, type FiltersData } from "@javaabu/filters" +import { type LaravelResourceCollection, LinkButton, Pagination, TableCell, TableEmptyRow, TableHeading, TableRow } from "@javaabu/forms" +import { __ } from "@javaabu/i18n" +import type { Product, Category } from "./types" + +interface Props { + category: Category + products: Omit & { data: Product[] } + filters: FiltersData + bulkActions: BulkActionsSchema + createUrl: string | null +} + +export default function Index({ category, products, filters, bulkActions, createUrl }: Props) { + return ( + <> + + +
+
+ +

{__("Products")}

+
+ + {createUrl && ( + + {__("New product")} + + )} +
+ + + + router.reload()} + headers={ + <> + + + + } + pagination={products.meta.last_page > 1 ? : null} + > + {products.data.length > 0 ? ( + products.data.map((product) => ( + + + {product.can.view ? ( + + {product.name} + + ) : ( + product.name + )} + + + + )) + ) : ( + + {filters.active > 0 + ? __("No products match these filters.") + : __("No products yet.")} + + )} + + + ) +} + +// resources/js/pages/admin/products/Create.tsx +import { Head } from "@inertiajs/react" +import { __ } from "@javaabu/i18n" +import { StencilForm, type StencilSchema } from "@javaabu/stencil" +import type { Category } from "./types" + +interface Props { + category: Category + form: StencilSchema +} + +export default function Create({ category, form }: Props) { + return ( + <> + + +
+
+ +

{__("New product")}

+
+
+ + + + ) +} + +// resources/js/pages/admin/products/Edit.tsx +import { Head } from "@inertiajs/react" +import { LinkButton } from "@javaabu/forms" +import { __ } from "@javaabu/i18n" +import { StencilForm, type StencilSchema } from "@javaabu/stencil" +import type { Product, Category } from "./types" + +interface Props { + category: Category + product: Product + form: StencilSchema +} + +export default function Edit({ category, product, form }: Props) { + return ( + <> + + +
+
+ +

{__("Edit product")}

+
+ + + {__("View")} + +
+ + + + ) +} + +// resources/js/pages/admin/products/Show.tsx +import { Head, router } from "@inertiajs/react" +import { Alert, BooleanEntry, Button, Infolist, LinkButton, TextEntry } from "@javaabu/forms" +import { __ } from "@javaabu/i18n" +import type { Product, Category } from "./types" + +interface Props { + category: Category + product: Product +} + +export default function Show({ category, product }: Props) { + return ( + <> + + +
+
+ +

{product.name}

+
+ +
+ {product.deleted_at ? ( + <> + {product.can.restore && ( + + )} + {product.can.forceDelete && ( + + )} + + ) : ( + <> + {product.can.update && ( + + {__("Edit")} + + )} + {product.can.delete && ( + + )} + + )} +
+
+ + {product.deleted_at && ( + + {__("This product is deleted.")} + + )} + + + + + + + + + + + + + + + + + + related.name).join(", ")} /> + + + ) +} + +// tests/Feature/Admin/ProductsControllerTest.php +artisan('permissions:sync'); + } + + #[Test] + public function it_lists_the_products(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(); + + $this->actingAsAdmin('view_products'); + + $this->get(route('admin.categories.products.index', $category)) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('admin/products/Index') + ->has('products.data', 1) + ); + } + + #[Test] + public function it_does_not_list_the_products_without_permission(): void + { + $category = Category::factory()->create(); + $this->actingAsAdmin(); + + $this->get(route('admin.categories.products.index', $category)) + ->assertForbidden(); + } + + #[Test] + public function it_searches_the_products(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(['name' => 'ރަސްމީ ދުވަސް']); + $other = Product::factory()->for($category, 'category')->create(['name' => 'Something else']); + + $this->actingAsAdmin('view_products'); + + $this->get(route('admin.categories.products.index', [$category, 'search' => 'ރަސްމީ'])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('admin/products/Index') + ->has('products.data', 1) + ->where('products.data.0.id', $product->id) + ); + } + + #[Test] + public function it_shows_the_new_product_form(): void + { + $category = Category::factory()->create(); + $this->actingAsAdmin('edit_products'); + + $this->get(route('admin.categories.products.create', $category)) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('admin/products/Create') + ->has('form') + ); + } + + #[Test] + public function it_creates_a_product(): void + { + $category = Category::factory()->create(); + $this->actingAsAdmin('edit_products'); + + $this->post(route('admin.categories.products.store', $category), [ + 'name' => 'ރަސްމީ', + 'price' => 10.5, + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); + + $this->assertDatabaseHas('products', [ + 'category_id' => $category->id, + 'name' => 'ރަސްމީ', + 'price' => 10.5, + ]); + } + + #[Test] + public function it_validates_new_products(): void + { + $category = Category::factory()->create(); + $this->actingAsAdmin('edit_products'); + + $this->post(route('admin.categories.products.store', $category), [ + 'name' => [], + 'price' => 'ބަދަލު', + ]) + ->assertSessionHasErrors(['name', 'price']); + + $this->assertDatabaseCount('products', 0); + } + + #[Test] + public function it_does_not_create_products_without_permission(): void + { + $category = Category::factory()->create(); + $this->actingAsAdmin('view_products'); + + $this->post(route('admin.categories.products.store', $category), []) + ->assertForbidden(); + + $this->assertDatabaseCount('products', 0); + } + + #[Test] + public function it_shows_a_product(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(); + + $this->actingAsAdmin('view_products'); + + $this->get(route('admin.categories.products.show', [$category, $product])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('admin/products/Show') + ->where('product.id', $product->id) + ); + } + + #[Test] + public function it_shows_the_edit_product_form(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(); + + $this->actingAsAdmin('edit_products'); + + $this->get(route('admin.categories.products.edit', [$category, $product])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('admin/products/Edit') + ->has('form') + ); + } + + #[Test] + public function it_updates_a_product(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(); + + $this->actingAsAdmin('edit_products'); + + $this->patch(route('admin.categories.products.update', [$category, $product]), [ + 'name' => 'ދިވެހި', + 'price' => 20.25, + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); + + $this->assertDatabaseHas('products', [ + 'id' => $product->id, + 'name' => 'ދިވެހި', + 'price' => 20.25, + ]); + } + + #[Test] + public function it_does_not_update_products_without_permission(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(); + + $this->actingAsAdmin('view_products'); + + $this->patch(route('admin.categories.products.update', [$category, $product]), []) + ->assertForbidden(); + } + + #[Test] + public function it_deletes_a_product(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(); + + $this->actingAsAdmin('delete_products'); + + $this->delete(route('admin.categories.products.destroy', [$category, $product])) + ->assertRedirect(route('admin.categories.products.index', $category)); + + $this->assertSoftDeleted($product); + } + + #[Test] + public function it_restores_a_deleted_product(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(); + $product->delete(); + + $this->actingAsAdmin('delete_products'); + + $this->patch(route('admin.categories.products.restore', [$category, $product])) + ->assertRedirect(); + + $this->assertNotSoftDeleted($product); + } + + #[Test] + public function it_permanently_deletes_a_product(): void + { + $category = Category::factory()->create(); + $product = Product::factory()->for($category, 'category')->create(); + $product->delete(); + + $this->actingAsAdmin('force_delete_products'); + + $this->delete(route('admin.categories.products.force-delete', [$category, $product])) + ->assertRedirect(route('admin.categories.products.index', $category)); + + $this->assertModelMissing($product); + } + + #[Test] + public function it_deletes_products_in_bulk(): void + { + $category = Category::factory()->create(); + $products = Product::factory()->for($category, 'category')->count(2)->create(); + + $this->actingAsAdmin('view_products', 'delete_products'); + + $this->postJson(route('admin.categories.products.bulk', $category), [ + 'action' => 'delete', + 'products' => $products->pluck('id')->all(), + ]) + ->assertOk() + ->assertJson(['succeeded' => 2]); + + $products->each(fn (Product $record) => $this->assertSoftDeleted($record)); + } + + /** + * Log in as a user of the admin area with these permissions. + */ + protected function actingAsAdmin(string ...$permissions): User + { + $user = User::factory()->create(); + + if ($permissions) { + $user->givePermissionTo($permissions); + } + + $this->actingAs($user); + + return $user; + } +} diff --git a/tests/snapshots/products.blade.txt b/tests/snapshots/products.blade.txt new file mode 100644 index 0000000..ce5570a --- /dev/null +++ b/tests/snapshots/products.blade.txt @@ -0,0 +1,1017 @@ +// app/Models/Product.php + */ + use HasFactory; + + use SoftDeletes; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'address', + 'slug', + 'description', + 'price', + 'stock', + 'on_sale', + 'features', + 'published_at', + 'expire_at', + 'released_on', + 'sale_time', + 'status', + 'category_id', + 'manufactured_year', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'price' => 'decimal:2', + 'stock' => 'integer', + 'on_sale' => 'boolean', + 'published_at' => 'datetime', + 'expire_at' => 'datetime', + 'released_on' => 'date', + 'manufactured_year' => 'integer', + ]; + } + + /** + * @return BelongsTo + */ + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + /** + * @return HasMany + */ + public function allForeigns(): HasMany + { + return $this->hasMany(AllForeign::class, 'product_slug', 'slug'); + } + + /** + * @return HasMany + */ + public function orders(): HasMany + { + return $this->hasMany(Order::class, 'product_slug', 'slug'); + } + + /** + * @return HasMany + */ + public function singleValueEnumOrders(): HasMany + { + return $this->hasMany(SingleValueEnumOrder::class, 'product_slug', 'slug'); + } + + /** + * @return BelongsToMany + */ + public function tags(): BelongsToMany + { + return $this->belongsToMany(Tag::class); + } +} + +// database/factories/ProductFactory.php + + */ +class ProductFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = Product::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->text(50), + 'address' => fake()->text(50), + 'slug' => fake()->unique()->text(50), + 'description' => fake()->optional()->paragraph(), + 'price' => fake()->randomFloat(2, 0, 10000), + 'stock' => fake()->numberBetween(0, 10000), + 'on_sale' => fake()->boolean(), + 'features' => fake()->paragraph(), + 'published_at' => fake()->dateTimeBetween('-1 year', '+1 year'), + 'expire_at' => fake()->dateTimeBetween('-1 year', '+1 year'), + 'released_on' => fake()->date(), + 'sale_time' => fake()->time('H:i'), + 'status' => fake()->randomElement(['draft', 'published']), + 'category_id' => fn () => fake()->boolean() ? Category::factory() : null, + 'manufactured_year' => fake()->numberBetween(0, 10000), + ]; + } +} + +// app/Policies/ProductPolicy.php +label(__('Search'))->columns(['name', 'address', 'slug', 'description', 'features']), + NumberRangeFilter::make('price')->label(__('Price')), + NumberRangeFilter::make('stock')->label(__('Stock'))->integer(), + TernaryFilter::make('on_sale')->label(__('On sale')), + SelectFilter::make('status')->label(__('Status'))->options(['draft' => __('Draft'), 'published' => __('Published')]), + SelectFilter::make('category_id')->label(__('Category'))->relationship('category', 'name')->searchable(), + SelectFilter::make('tags')->label(__('Tags'))->relationship('tags', 'name')->multiple(), + DateRangeFilter::make('created_at')->label(__('Created at')), + TrashedFilter::make(), + ]; + } + + protected function sorts(): array + { + return [ + Sort::make('name')->label(__('Name')), + Sort::make('address')->label(__('Address')), + Sort::make('slug')->label(__('Slug')), + Sort::make('price')->label(__('Price')), + Sort::make('stock')->label(__('Stock')), + Sort::make('created_at')->label(__('Created at')), + Sort::make('updated_at')->label(__('Updated at')), + ]; + } +} + +// app/BulkActions/ProductBulkActions.php +filters(ProductFilters::class); + } + + protected function actions(): array + { + return [ + DeleteAction::make()->can('delete_products'), + RestoreAction::make()->can('delete_products'), + ForceDeleteAction::make()->can('force_delete_products'), + ]; + } +} + +// app/Stencil/ProductForm.php +columns(2) + ->schema([ + TextInput::make('name') + ->label(__('Name')) + ->required() + ->maxLength(255), + TextInput::make('address') + ->label(__('Address')) + ->required() + ->maxLength(255), + TextInput::make('slug') + ->label(__('Slug')) + ->required() + ->maxLength(255) + ->unique(), + Textarea::make('description') + ->label(__('Description')) + ->rows(4) + ->maxLength(65535) + ->columnSpanFull(), + TextInput::make('price') + ->label(__('Price')) + ->required() + ->numeric() + ->decimals(2) + ->minValue(-99999999) + ->maxValue(99999999), + TextInput::make('stock') + ->label(__('Stock')) + ->required() + ->integer() + ->minValue(-2147483648) + ->maxValue(2147483647), + Checkbox::make('on_sale')->label(__('On sale')), + Textarea::make('features') + ->label(__('Features')) + ->required() + ->rows(4) + ->maxLength(65535) + ->columnSpanFull(), + DateTimePicker::make('published_at')->label(__('Published at'))->required(), + DateTimePicker::make('expire_at')->label(__('Expire at'))->required(), + DatePicker::make('released_on')->label(__('Released on'))->required(), + TimePicker::make('sale_time')->label(__('Sale time'))->required(), + Select::make('status') + ->label(__('Status')) + ->required() + ->options(['draft' => __('Draft'), 'published' => __('Published')]), + Select::make('category_id') + ->label(__('Category')) + ->relationship('category', 'name') + ->searchable(), + TextInput::make('manufactured_year') + ->label(__('Manufactured year')) + ->required() + ->integer() + ->minValue(-2147483648) + ->maxValue(2147483647), + Select::make('tags') + ->label(__('Tags')) + ->multiple() + ->relationship('tags', 'name') + ->searchable(), + ]), + ]; + } +} + +// app/Http/Controllers/Admin/ProductsController.php +apply(Product::query()) + ->paginate($request->integer('per_page', 20)) + ->withQueryString(); + + return view('admin.products.index', [ + 'products' => $products, + 'filters' => $filters, + 'bulk_actions' => ProductBulkActions::make()->paginator($products), + ]); + } + + /** + * Show the form for a new product. + */ + public function create(): View + { + Gate::authorize('create', Product::class); + + return view('admin.products.create', [ + 'form' => ProductForm::make()->model(Product::class)->action(route('admin.products.store')), + ]); + } + + /** + * Save a new product. + */ + public function store(): RedirectResponse + { + Gate::authorize('create', Product::class); + + $product = ProductForm::make()->model(Product::class)->save(); + + return redirect(route('admin.products.show', $product)) + ->with('status', __('Product created.')); + } + + /** + * Show a product. + */ + public function show(Product $product): View + { + Gate::authorize('view', $product); + + $product->load(['category', 'tags']); + + return view('admin.products.show', [ + 'product' => $product, + ]); + } + + /** + * Show the form for editing a product. + */ + public function edit(Product $product): View + { + Gate::authorize('update', $product); + + return view('admin.products.edit', [ + 'product' => $product, + 'form' => ProductForm::make()->model($product)->action(route('admin.products.update', $product)), + ]); + } + + /** + * Save the changes to a product. + */ + public function update(Product $product): RedirectResponse + { + Gate::authorize('update', $product); + + ProductForm::make()->model($product)->save(); + + return redirect(route('admin.products.show', $product)) + ->with('status', __('Product updated.')); + } + + /** + * Delete a product. + */ + public function destroy(Product $product): RedirectResponse + { + Gate::authorize('delete', $product); + + $product->delete(); + + return redirect(route('admin.products.index')) + ->with('status', __('Product deleted.')); + } + + /** + * Restore a deleted product. + */ + public function restore(Product $product): RedirectResponse + { + Gate::authorize('restore', $product); + + $product->restore(); + + return redirect(route('admin.products.show', $product)) + ->with('status', __('Product restored.')); + } + + /** + * Permanently delete a product. + */ + public function forceDelete(Product $product): RedirectResponse + { + Gate::authorize('forceDelete', $product); + + $product->forceDelete(); + + return redirect(route('admin.products.index')) + ->with('status', __('Product permanently deleted.')); + } +} + +// routes/admin.php +// Products +Route::resource('products', ProductsController::class)->withTrashed(['show']); +Route::patch('products/{product}/restore', [ProductsController::class, 'restore'])->withTrashed()->name('products.restore'); +Route::delete('products/{product}/force-delete', [ProductsController::class, 'forceDelete'])->withTrashed()->name('products.force-delete'); +Route::bulkActions('products', ProductBulkActions::class); +// routes/web.php +require __DIR__ . '/admin.php'; +// tests/Feature/Admin/ProductsControllerTest.php +artisan('permissions:sync'); + } + + #[Test] + public function it_lists_the_products(): void + { + $product = Product::factory()->create(); + + $this->actingAsAdmin('view_products'); + + $this->get(route('admin.products.index')) + ->assertOk() + ->assertSee($product->name); + } + + #[Test] + public function it_does_not_list_the_products_without_permission(): void + { + $this->actingAsAdmin(); + + $this->get(route('admin.products.index')) + ->assertForbidden(); + } + + #[Test] + public function it_searches_the_products(): void + { + $product = Product::factory()->create(['name' => 'ރަސްމީ ދުވަސް']); + $other = Product::factory()->create(['name' => 'Something else']); + + $this->actingAsAdmin('view_products'); + + $this->get(route('admin.products.index', ['search' => 'ރަސްމީ'])) + ->assertOk() + ->assertSee('ރަސްމީ ދުވަސް') + ->assertDontSee('Something else'); + } + + #[Test] + public function it_shows_the_new_product_form(): void + { + $this->actingAsAdmin('edit_products'); + + $this->get(route('admin.products.create')) + ->assertOk() + ->assertSee(__('New product')); + } + + #[Test] + public function it_creates_a_product(): void + { + $category = Category::factory()->create(); + $other_category = Category::factory()->create(); + $tags = Tag::factory()->count(2)->create(); + + $this->actingAsAdmin('edit_products'); + + $this->post(route('admin.products.store'), [ + 'name' => 'ރަސްމީ', + 'address' => 'ރަސްމީ', + 'slug' => 'ރަސްމީ', + 'description' => 'ދިވެހިރާއްޖޭގެ ޖުމްހޫރިއްޔާ', + 'price' => 10.5, + 'stock' => 10, + 'on_sale' => true, + 'features' => 'ދިވެހިރާއްޖޭގެ ޖުމްހޫރިއްޔާ', + 'published_at' => '2024-02-12 14:54', + 'expire_at' => '2024-02-12 14:54', + 'released_on' => '2024-02-12', + 'sale_time' => '14:54', + 'status' => 'draft', + 'category_id' => $category->id, + 'manufactured_year' => 10, + 'tags' => $tags->pluck('id')->all(), + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); + + $this->assertDatabaseHas('products', [ + 'name' => 'ރަސްމީ', + 'address' => 'ރަސްމީ', + 'slug' => 'ރަސްމީ', + 'description' => 'ދިވެހިރާއްޖޭގެ ޖުމްހޫރިއްޔާ', + 'price' => 10.5, + 'stock' => 10, + 'on_sale' => true, + 'features' => 'ދިވެހިރާއްޖޭގެ ޖުމްހޫރިއްޔާ', + 'status' => 'draft', + 'category_id' => $category->id, + 'manufactured_year' => 10, + ]); + + $product = Product::query()->latest('id')->first(); + + $this->assertEquals('2024-02-12 14:54', $product->published_at?->format('Y-m-d H:i')); + $this->assertEquals('2024-02-12 14:54', $product->expire_at?->format('Y-m-d H:i')); + $this->assertEquals('2024-02-12', $product->released_on?->format('Y-m-d')); + $this->assertEquals('14:54', substr((string) $product->sale_time, 0, 5)); + $this->assertEquals($tags->pluck('id')->all(), $product->tags()->pluck('tags.id')->sort()->values()->all()); + } + + #[Test] + public function it_validates_new_products(): void + { + $this->actingAsAdmin('edit_products'); + + $this->post(route('admin.products.store'), [ + 'name' => [], + 'address' => [], + 'slug' => [], + 'description' => [], + 'price' => 'ބަދަލު', + 'stock' => 'ބަދަލު', + 'features' => [], + 'published_at' => 'ބަދަލު', + 'expire_at' => 'ބަދަލު', + 'released_on' => 'ބަދަލު', + 'sale_time' => 'ބަދަލު', + 'status' => 'ބަދަލު', + 'category_id' => -1, + 'manufactured_year' => 'ބަދަލު', + ]) + ->assertSessionHasErrors(['name', 'address', 'slug', 'description', 'price', 'stock', 'features', 'published_at', 'expire_at', 'released_on', 'sale_time', 'status', 'category_id', 'manufactured_year']); + + $this->assertDatabaseCount('products', 0); + } + + #[Test] + public function it_does_not_create_products_without_permission(): void + { + $this->actingAsAdmin('view_products'); + + $this->post(route('admin.products.store'), []) + ->assertForbidden(); + + $this->assertDatabaseCount('products', 0); + } + + #[Test] + public function it_shows_a_product(): void + { + $product = Product::factory()->create(); + + $this->actingAsAdmin('view_products'); + + $this->get(route('admin.products.show', $product)) + ->assertOk() + ->assertSee($product->name); + } + + #[Test] + public function it_shows_the_edit_product_form(): void + { + $product = Product::factory()->create(); + + $this->actingAsAdmin('edit_products'); + + $this->get(route('admin.products.edit', $product)) + ->assertOk() + ->assertSee(__('Edit product')); + } + + #[Test] + public function it_updates_a_product(): void + { + $category = Category::factory()->create(); + $other_category = Category::factory()->create(); + $tags = Tag::factory()->count(2)->create(); + + $product = Product::factory()->create(); + + $this->actingAsAdmin('edit_products'); + + $this->patch(route('admin.products.update', $product), [ + 'name' => 'ދިވެހި', + 'address' => 'ދިވެހި', + 'slug' => 'ދިވެހި', + 'description' => 'Republic of Maldives', + 'price' => 20.25, + 'stock' => 20, + 'on_sale' => false, + 'features' => 'Republic of Maldives', + 'published_at' => '2025-11-06 09:30', + 'expire_at' => '2025-11-06 09:30', + 'released_on' => '2025-11-06', + 'sale_time' => '09:30', + 'status' => 'published', + 'category_id' => $other_category->id, + 'manufactured_year' => 20, + 'tags' => [$tags->first()->id], + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); + + $this->assertDatabaseHas('products', [ + 'id' => $product->id, + 'name' => 'ދިވެހި', + 'address' => 'ދިވެހި', + 'slug' => 'ދިވެހި', + 'description' => 'Republic of Maldives', + 'price' => 20.25, + 'stock' => 20, + 'on_sale' => false, + 'features' => 'Republic of Maldives', + 'status' => 'published', + 'category_id' => $other_category->id, + 'manufactured_year' => 20, + ]); + + $product->refresh(); + + $this->assertEquals('2025-11-06 09:30', $product->published_at?->format('Y-m-d H:i')); + $this->assertEquals('2025-11-06 09:30', $product->expire_at?->format('Y-m-d H:i')); + $this->assertEquals('2025-11-06', $product->released_on?->format('Y-m-d')); + $this->assertEquals('09:30', substr((string) $product->sale_time, 0, 5)); + $this->assertEquals([$tags->first()->id], $product->tags()->pluck('tags.id')->sort()->values()->all()); + } + + #[Test] + public function it_does_not_update_products_without_permission(): void + { + $product = Product::factory()->create(); + + $this->actingAsAdmin('view_products'); + + $this->patch(route('admin.products.update', $product), []) + ->assertForbidden(); + } + + #[Test] + public function it_deletes_a_product(): void + { + $product = Product::factory()->create(); + + $this->actingAsAdmin('delete_products'); + + $this->delete(route('admin.products.destroy', $product)) + ->assertRedirect(route('admin.products.index')); + + $this->assertSoftDeleted($product); + } + + #[Test] + public function it_restores_a_deleted_product(): void + { + $product = Product::factory()->create(); + $product->delete(); + + $this->actingAsAdmin('delete_products'); + + $this->patch(route('admin.products.restore', $product)) + ->assertRedirect(); + + $this->assertNotSoftDeleted($product); + } + + #[Test] + public function it_permanently_deletes_a_product(): void + { + $product = Product::factory()->create(); + $product->delete(); + + $this->actingAsAdmin('force_delete_products'); + + $this->delete(route('admin.products.force-delete', $product)) + ->assertRedirect(route('admin.products.index')); + + $this->assertModelMissing($product); + } + + #[Test] + public function it_deletes_products_in_bulk(): void + { + $products = Product::factory()->count(2)->create(); + + $this->actingAsAdmin('view_products', 'delete_products'); + + $this->postJson(route('admin.products.bulk'), [ + 'action' => 'delete', + 'products' => $products->pluck('id')->all(), + ]) + ->assertOk() + ->assertJson(['succeeded' => 2]); + + $products->each(fn (Product $record) => $this->assertSoftDeleted($record)); + } + + /** + * Log in as a user of the admin area with these permissions. + */ + protected function actingAsAdmin(string ...$permissions): User + { + $user = User::factory()->create(); + + if ($permissions) { + $user->givePermissionTo($permissions); + } + + $this->actingAs($user); + + return $user; + } +} + +// resources/views/admin/products/index.blade.php +@extends('layouts.admin') + +@section('title', __('Products')) + +@section('content') +
+
+

{{ __('Products') }}

+
+ + @can('create', \App\Models\Product::class) + + {{ __('New product') }} + + @endcan +
+ + + + + + + + + + + + + + + + + + @forelse($products as $product) + + + @can('view', $product) + {{ $product->name }} + @else + {{ $product->name }} + @endcan + + + + + + + @empty + + {{ $filters->hasActiveFilters() ? __('No products match these filters.') : __('No products yet.') }} + + @endforelse + + + @if($products->hasPages()) + + + + @endif + +@endsection + +// resources/views/admin/products/create.blade.php +@extends('layouts.admin') + +@section('title', __('New product')) + +@section('content') +
+ +

{{ __('New product') }}

+
+ + {{ $form }} +@endsection + +// resources/views/admin/products/edit.blade.php +@extends('layouts.admin') + +@section('title', __('Edit product')) + +@section('content') +
+
+ +

{{ __('Edit product') }}

+
+ + + {{ __('View') }} + +
+ + {{ $form }} +@endsection + +// resources/views/admin/products/show.blade.php +@extends('layouts.admin') + +@section('title', $product->name) + +@section('content') +
+
+ +

{{ $product->name }}

+
+ +
+ @if($product->trashed()) + @can('restore', $product) +
+ @csrf + @method('PATCH') + + + {{ __('Restore') }} + +
+ @endcan + + @can('forceDelete', $product) +
+ @csrf + @method('DELETE') + + + {{ __('Delete permanently') }} + +
+ @endcan + @else + @can('update', $product) + + {{ __('Edit') }} + + @endcan + + @can('delete', $product) +
+ @csrf + @method('DELETE') + + + {{ __('Delete') }} + +
+ @endcan + @endif +
+
+ + @if($product->trashed()) + + {{ __('This product is deleted.') }} + + @endif + + + + + + + + + + + + + + + + + + + +@endsection + +// app/Exports/ProductsExport.php +apply(Product::query()) + ->paginate($request->integer('per_page', 20)) + ->withQueryString(); + + return Inertia::render('admin/products/Index', [ + 'products' => ProductResource::collection($products), + 'filters' => $filters, + 'bulkActions' => ProductBulkActions::make()->paginator($products), + 'createUrl' => Gate::allows('create', Product::class) ? route('admin.products.create') : null, + ]); + } + + /** + * Show the form for a new product. + */ + public function create(): Response + { + Gate::authorize('create', Product::class); + + return Inertia::render('admin/products/Create', [ + 'form' => ProductForm::make()->model(Product::class)->action(route('admin.products.store')), + ]); + } + + /** + * Save a new product. + */ + public function store(): RedirectResponse + { + Gate::authorize('create', Product::class); + + $product = ProductForm::make()->model(Product::class)->save(); + + return redirect(route('admin.products.show', $product)) + ->with('status', __('Product created.')); + } + + /** + * Show a product. + */ + public function show(Product $product): Response + { + Gate::authorize('view', $product); + + $product->load(['category', 'tags']); + + return Inertia::render('admin/products/Show', [ + 'product' => ProductResource::make($product)->resolve(), + ]); + } + + /** + * Show the form for editing a product. + */ + public function edit(Product $product): Response + { + Gate::authorize('update', $product); + + return Inertia::render('admin/products/Edit', [ + 'product' => ProductResource::make($product)->resolve(), + 'form' => ProductForm::make()->model($product)->action(route('admin.products.update', $product)), + ]); + } + + /** + * Save the changes to a product. + */ + public function update(Product $product): RedirectResponse + { + Gate::authorize('update', $product); + + ProductForm::make()->model($product)->save(); + + return redirect(route('admin.products.show', $product)) + ->with('status', __('Product updated.')); + } + + /** + * Delete a product. + */ + public function destroy(Product $product): RedirectResponse + { + Gate::authorize('delete', $product); + + $product->delete(); + + return redirect(route('admin.products.index')) + ->with('status', __('Product deleted.')); + } + + /** + * Restore a deleted product. + */ + public function restore(Product $product): RedirectResponse + { + Gate::authorize('restore', $product); + + $product->restore(); + + return redirect(route('admin.products.show', $product)) + ->with('status', __('Product restored.')); + } + + /** + * Permanently delete a product. + */ + public function forceDelete(Product $product): RedirectResponse + { + Gate::authorize('forceDelete', $product); + + $product->forceDelete(); + + return redirect(route('admin.products.index')) + ->with('status', __('Product permanently deleted.')); + } +} + +// app/Http/Resources/Admin/ProductResource.php + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'address' => $this->address, + 'slug' => $this->slug, + 'description' => $this->description, + 'price' => $this->price, + 'stock' => $this->stock, + 'on_sale' => $this->on_sale, + 'features' => $this->features, + 'published_at' => $this->published_at?->format('Y-m-d H:i'), + 'expire_at' => $this->expire_at?->format('Y-m-d H:i'), + 'released_on' => $this->released_on?->format('Y-m-d'), + 'sale_time' => $this->sale_time, + 'status' => $this->status, + 'category_id' => $this->category_id, + 'category' => $this->whenLoaded('category', fn () => [ + 'id' => $this->category->id, + 'name' => $this->category->name, + ]), + 'manufactured_year' => $this->manufactured_year, + 'tags' => $this->whenLoaded('tags', fn () => $this->tags->map(fn (Tag $tag) => [ + 'id' => $tag->getKey(), + 'name' => $tag->name, + ])->all()), + 'created_at' => $this->created_at?->format('Y-m-d H:i'), + 'updated_at' => $this->updated_at?->format('Y-m-d H:i'), + 'deleted_at' => $this->deleted_at?->format('Y-m-d H:i'), + 'urls' => [ + 'show' => route('admin.products.show', $this->resource), + 'edit' => route('admin.products.edit', $this->resource), + 'destroy' => route('admin.products.destroy', $this->resource), + 'restore' => route('admin.products.restore', $this->resource), + 'force_delete' => route('admin.products.force-delete', $this->resource), + ], + 'can' => [ + 'view' => (bool) $request->user()?->can('view', $this->resource), + 'update' => (bool) $request->user()?->can('update', $this->resource), + 'delete' => (bool) $request->user()?->can('delete', $this->resource), + 'restore' => (bool) $request->user()?->can('restore', $this->resource), + 'forceDelete' => (bool) $request->user()?->can('forceDelete', $this->resource), + ], + ]; + } +} + +// resources/js/pages/admin/products/types.ts +/** A product, as ProductResource sends it. */ +export interface Product { + id: number + name: string + address: string + slug: string + description: string | null + price: string + stock: number + on_sale: boolean + features: string + published_at: string + expire_at: string + released_on: string + sale_time: string + status: "draft" | "published" + category_id: number | null + category?: { id: number; name: string } | null + manufactured_year: number + tags?: { id: number; name: string }[] + created_at: string | null + updated_at: string | null + deleted_at: string | null + urls: { show: string; edit: string; destroy: string; restore: string; force_delete: string } + can: { view: boolean; update: boolean; delete: boolean; restore: boolean; forceDelete: boolean } +} + +// resources/js/pages/admin/products/Index.tsx +import { Head, Link, router } from "@inertiajs/react" +import { BulkActionsTable, type BulkActionsSchema } from "@javaabu/bulk-actions" +import { FilterBar, SortableHeading, type FiltersData } from "@javaabu/filters" +import { type LaravelResourceCollection, LinkButton, Pagination, TableCell, TableEmptyRow, TableHeading, TableRow } from "@javaabu/forms" +import { __ } from "@javaabu/i18n" +import type { Product } from "./types" + +interface Props { + products: Omit & { data: Product[] } + filters: FiltersData + bulkActions: BulkActionsSchema + createUrl: string | null +} + +export default function Index({ products, filters, bulkActions, createUrl }: Props) { + return ( + <> + + +
+
+

{__("Products")}

+
+ + {createUrl && ( + + {__("New product")} + + )} +
+ + + + router.reload()} + headers={ + <> + + + + + + + } + pagination={products.meta.last_page > 1 ? : null} + > + {products.data.length > 0 ? ( + products.data.map((product) => ( + + + {product.can.view ? ( + + {product.name} + + ) : ( + product.name + )} + + + + + + + )) + ) : ( + + {filters.active > 0 + ? __("No products match these filters.") + : __("No products yet.")} + + )} + + + ) +} + +// resources/js/pages/admin/products/Create.tsx +import { Head } from "@inertiajs/react" +import { __ } from "@javaabu/i18n" +import { StencilForm, type StencilSchema } from "@javaabu/stencil" + +interface Props { + form: StencilSchema +} + +export default function Create({ form }: Props) { + return ( + <> + + +
+
+

{__("New product")}

+
+
+ + + + ) +} + +// resources/js/pages/admin/products/Edit.tsx +import { Head } from "@inertiajs/react" +import { LinkButton } from "@javaabu/forms" +import { __ } from "@javaabu/i18n" +import { StencilForm, type StencilSchema } from "@javaabu/stencil" +import type { Product } from "./types" + +interface Props { + product: Product + form: StencilSchema +} + +export default function Edit({ product, form }: Props) { + return ( + <> + + +
+
+

{__("Edit product")}

+
+ + + {__("View")} + +
+ + + + ) +} + +// resources/js/pages/admin/products/Show.tsx +import { Head, router } from "@inertiajs/react" +import { Alert, BooleanEntry, Button, Infolist, LinkButton, TextEntry } from "@javaabu/forms" +import { __ } from "@javaabu/i18n" +import type { Product } from "./types" + +interface Props { + product: Product +} + +export default function Show({ product }: Props) { + return ( + <> + + +
+
+

{product.name}

+
+ +
+ {product.deleted_at ? ( + <> + {product.can.restore && ( + + )} + {product.can.forceDelete && ( + + )} + + ) : ( + <> + {product.can.update && ( + + {__("Edit")} + + )} + {product.can.delete && ( + + )} + + )} +
+
+ + {product.deleted_at && ( + + {__("This product is deleted.")} + + )} + + + + + + + + + + + + + + + + + + related.name).join(", ")} /> + + + ) +} + +// tests/Feature/Admin/ProductsControllerTest.php +artisan('permissions:sync'); + } + + #[Test] + public function it_lists_the_products(): void + { + $product = Product::factory()->create(); + + $this->actingAsAdmin('view_products'); + + $this->get(route('admin.products.index')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('admin/products/Index') + ->has('products.data', 1) + ); + } + + #[Test] + public function it_does_not_list_the_products_without_permission(): void + { + $this->actingAsAdmin(); + + $this->get(route('admin.products.index')) + ->assertForbidden(); + } + + #[Test] + public function it_searches_the_products(): void + { + $product = Product::factory()->create(['name' => 'ރަސްމީ ދުވަސް']); + $other = Product::factory()->create(['name' => 'Something else']); + + $this->actingAsAdmin('view_products'); + + $this->get(route('admin.products.index', ['search' => 'ރަސްމީ'])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('admin/products/Index') + ->has('products.data', 1) + ->where('products.data.0.id', $product->id) + ); + } + + #[Test] + public function it_shows_the_new_product_form(): void + { + $this->actingAsAdmin('edit_products'); + + $this->get(route('admin.products.create')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('admin/products/Create') + ->has('form') + ); + } + + #[Test] + public function it_creates_a_product(): void + { + $category = Category::factory()->create(); + $other_category = Category::factory()->create(); + $tags = Tag::factory()->count(2)->create(); + + $this->actingAsAdmin('edit_products'); + + $this->post(route('admin.products.store'), [ + 'name' => 'ރަސްމީ', + 'address' => 'ރަސްމީ', + 'slug' => 'ރަސްމީ', + 'description' => 'ދިވެހިރާއްޖޭގެ ޖުމްހޫރިއްޔާ', + 'price' => 10.5, + 'stock' => 10, + 'on_sale' => true, + 'features' => 'ދިވެހިރާއްޖޭގެ ޖުމްހޫރިއްޔާ', + 'published_at' => '2024-02-12 14:54', + 'expire_at' => '2024-02-12 14:54', + 'released_on' => '2024-02-12', + 'sale_time' => '14:54', + 'status' => 'draft', + 'category_id' => $category->id, + 'manufactured_year' => 10, + 'tags' => $tags->pluck('id')->all(), + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); + + $this->assertDatabaseHas('products', [ + 'name' => 'ރަސްމީ', + 'address' => 'ރަސްމީ', + 'slug' => 'ރަސްމީ', + 'description' => 'ދިވެހިރާއްޖޭގެ ޖުމްހޫރިއްޔާ', + 'price' => 10.5, + 'stock' => 10, + 'on_sale' => true, + 'features' => 'ދިވެހިރާއްޖޭގެ ޖުމްހޫރިއްޔާ', + 'status' => 'draft', + 'category_id' => $category->id, + 'manufactured_year' => 10, + ]); + + $product = Product::query()->latest('id')->first(); + + $this->assertEquals('2024-02-12 14:54', $product->published_at?->format('Y-m-d H:i')); + $this->assertEquals('2024-02-12 14:54', $product->expire_at?->format('Y-m-d H:i')); + $this->assertEquals('2024-02-12', $product->released_on?->format('Y-m-d')); + $this->assertEquals('14:54', substr((string) $product->sale_time, 0, 5)); + $this->assertEquals($tags->pluck('id')->all(), $product->tags()->pluck('tags.id')->sort()->values()->all()); + } + + #[Test] + public function it_validates_new_products(): void + { + $this->actingAsAdmin('edit_products'); + + $this->post(route('admin.products.store'), [ + 'name' => [], + 'address' => [], + 'slug' => [], + 'description' => [], + 'price' => 'ބަދަލު', + 'stock' => 'ބަދަލު', + 'features' => [], + 'published_at' => 'ބަދަލު', + 'expire_at' => 'ބަދަލު', + 'released_on' => 'ބަދަލު', + 'sale_time' => 'ބަދަލު', + 'status' => 'ބަދަލު', + 'category_id' => -1, + 'manufactured_year' => 'ބަދަލު', + ]) + ->assertSessionHasErrors(['name', 'address', 'slug', 'description', 'price', 'stock', 'features', 'published_at', 'expire_at', 'released_on', 'sale_time', 'status', 'category_id', 'manufactured_year']); + + $this->assertDatabaseCount('products', 0); + } + + #[Test] + public function it_does_not_create_products_without_permission(): void + { + $this->actingAsAdmin('view_products'); + + $this->post(route('admin.products.store'), []) + ->assertForbidden(); + + $this->assertDatabaseCount('products', 0); + } + + #[Test] + public function it_shows_a_product(): void + { + $product = Product::factory()->create(); + + $this->actingAsAdmin('view_products'); + + $this->get(route('admin.products.show', $product)) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('admin/products/Show') + ->where('product.id', $product->id) + ); + } + + #[Test] + public function it_shows_the_edit_product_form(): void + { + $product = Product::factory()->create(); + + $this->actingAsAdmin('edit_products'); + + $this->get(route('admin.products.edit', $product)) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('admin/products/Edit') + ->has('form') + ); + } + + #[Test] + public function it_updates_a_product(): void + { + $category = Category::factory()->create(); + $other_category = Category::factory()->create(); + $tags = Tag::factory()->count(2)->create(); + + $product = Product::factory()->create(); + + $this->actingAsAdmin('edit_products'); + + $this->patch(route('admin.products.update', $product), [ + 'name' => 'ދިވެހި', + 'address' => 'ދިވެހި', + 'slug' => 'ދިވެހި', + 'description' => 'Republic of Maldives', + 'price' => 20.25, + 'stock' => 20, + 'on_sale' => false, + 'features' => 'Republic of Maldives', + 'published_at' => '2025-11-06 09:30', + 'expire_at' => '2025-11-06 09:30', + 'released_on' => '2025-11-06', + 'sale_time' => '09:30', + 'status' => 'published', + 'category_id' => $other_category->id, + 'manufactured_year' => 20, + 'tags' => [$tags->first()->id], + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); + + $this->assertDatabaseHas('products', [ + 'id' => $product->id, + 'name' => 'ދިވެހި', + 'address' => 'ދިވެހި', + 'slug' => 'ދިވެހި', + 'description' => 'Republic of Maldives', + 'price' => 20.25, + 'stock' => 20, + 'on_sale' => false, + 'features' => 'Republic of Maldives', + 'status' => 'published', + 'category_id' => $other_category->id, + 'manufactured_year' => 20, + ]); + + $product->refresh(); + + $this->assertEquals('2025-11-06 09:30', $product->published_at?->format('Y-m-d H:i')); + $this->assertEquals('2025-11-06 09:30', $product->expire_at?->format('Y-m-d H:i')); + $this->assertEquals('2025-11-06', $product->released_on?->format('Y-m-d')); + $this->assertEquals('09:30', substr((string) $product->sale_time, 0, 5)); + $this->assertEquals([$tags->first()->id], $product->tags()->pluck('tags.id')->sort()->values()->all()); + } + + #[Test] + public function it_does_not_update_products_without_permission(): void + { + $product = Product::factory()->create(); + + $this->actingAsAdmin('view_products'); + + $this->patch(route('admin.products.update', $product), []) + ->assertForbidden(); + } + + #[Test] + public function it_deletes_a_product(): void + { + $product = Product::factory()->create(); + + $this->actingAsAdmin('delete_products'); + + $this->delete(route('admin.products.destroy', $product)) + ->assertRedirect(route('admin.products.index')); + + $this->assertSoftDeleted($product); + } + + #[Test] + public function it_restores_a_deleted_product(): void + { + $product = Product::factory()->create(); + $product->delete(); + + $this->actingAsAdmin('delete_products'); + + $this->patch(route('admin.products.restore', $product)) + ->assertRedirect(); + + $this->assertNotSoftDeleted($product); + } + + #[Test] + public function it_permanently_deletes_a_product(): void + { + $product = Product::factory()->create(); + $product->delete(); + + $this->actingAsAdmin('force_delete_products'); + + $this->delete(route('admin.products.force-delete', $product)) + ->assertRedirect(route('admin.products.index')); + + $this->assertModelMissing($product); + } + + #[Test] + public function it_deletes_products_in_bulk(): void + { + $products = Product::factory()->count(2)->create(); + + $this->actingAsAdmin('view_products', 'delete_products'); + + $this->postJson(route('admin.products.bulk'), [ + 'action' => 'delete', + 'products' => $products->pluck('id')->all(), + ]) + ->assertOk() + ->assertJson(['succeeded' => 2]); + + $products->each(fn (Product $record) => $this->assertSoftDeleted($record)); + } + + /** + * Log in as a user of the admin area with these permissions. + */ + protected function actingAsAdmin(string ...$permissions): User + { + $user = User::factory()->create(); + + if ($permissions) { + $user->givePermissionTo($permissions); + } + + $this->actingAs($user); + + return $user; + } +} diff --git a/tests/snapshots/tags.a-pivot-on-the-other-side.txt b/tests/snapshots/tags.a-pivot-on-the-other-side.txt new file mode 100644 index 0000000..739885d --- /dev/null +++ b/tests/snapshots/tags.a-pivot-on-the-other-side.txt @@ -0,0 +1,64 @@ +// app/Models/Tag.php + */ + use HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + ]; + + /** + * @return BelongsToMany + */ + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class); + } +} + +// app/Stencil/TagForm.php +columns(2) + ->schema([ + TextInput::make('name') + ->label(__('Name')) + ->required() + ->maxLength(255), + Select::make('products') + ->label(__('Products')) + ->multiple() + ->relationship('products', 'name') + ->searchable(), + ]), + ]; + } +} diff --git a/tests/stubs/AfterAppend.php b/tests/stubs/AfterAppend.php deleted file mode 100644 index 6952b62..0000000 --- a/tests/stubs/AfterAppend.php +++ /dev/null @@ -1,7 +0,0 @@ -getHidden()); - } - - /** - * Get the allowed includes - * - * @return array - */ - public function getAllowedIncludes(): array - { - return [ - ]; - } - - /** - * Get the allowed appends - * - * @return array - */ - public function getAllowedAppends(): array - { - return [ - ]; - } - - /** - * Get the allowed sorts - * - * @return array - */ - public function getAllowedSorts(): array - { - return [ - 'id', - 'created_at', - 'updated_at', - 'name', - 'slug', - ]; - } - - /** - * Get the default sort - * - * @return string - */ - public function getDefaultSort(): string - { - return 'created_at'; - } - - /** - * Get the allowed filters - * - * @return array - */ - public function getAllowedFilters(): array - { - return [ - 'id', - 'name', - 'slug', - AllowedFilter::scope('search'), - ]; - } -} diff --git a/tests/stubs/Controllers/Api/ProductsController.php b/tests/stubs/Controllers/Api/ProductsController.php deleted file mode 100644 index c4ba4e0..0000000 --- a/tests/stubs/Controllers/Api/ProductsController.php +++ /dev/null @@ -1,104 +0,0 @@ -getHidden()); - } - - /** - * Get the allowed includes - * - * @return array - */ - public function getAllowedIncludes(): array - { - return [ - 'category', - ]; - } - - /** - * Get the allowed appends - * - * @return array - */ - public function getAllowedAppends(): array - { - return [ - ]; - } - - /** - * Get the allowed sorts - * - * @return array - */ - public function getAllowedSorts(): array - { - return [ - 'id', - 'created_at', - 'updated_at', - 'name', - 'address', - 'slug', - 'price', - 'stock', - 'published_at', - 'expire_at', - 'released_on', - 'sale_time', - 'manufactured_year', - ]; - } - - /** - * Get the default sort - * - * @return string - */ - public function getDefaultSort(): string - { - return 'created_at'; - } - - /** - * Get the allowed filters - * - * @return array - */ - public function getAllowedFilters(): array - { - return [ - 'id', - 'name', - 'address', - 'slug', - AllowedFilter::scope('search'), - ]; - } -} diff --git a/tests/stubs/Controllers/CategoriesController.php b/tests/stubs/Controllers/CategoriesController.php deleted file mode 100644 index 3bcc38f..0000000 --- a/tests/stubs/Controllers/CategoriesController.php +++ /dev/null @@ -1,169 +0,0 @@ -authorizeResource(Category::class); - } - - /** - * Initialize orderbys - */ - protected static function initOrderbys() - { - static::$orderbys = [ - 'id' => __('Id'), - 'created_at' => __('Created At'), - 'updated_at' => __('Updated At'), - 'name' => __('Name'), - 'slug' => __('Slug'), - ]; - } - - /** - * Display a listing of the resource. - */ - public function index(Request $request) - { - $title = __('All Categories'); - $orderby = $this->getOrderBy($request, 'created_at'); - $order = $this->getOrder($request, 'created_at', $orderby); - $per_page = $this->getPerPage($request); - - $categories = Category::orderBy($orderby, $order); - - $search = null; - if ($search = $request->input('search')) { - $categories->search($search); - $title = __('Categories matching \':search\'', ['search' => $search]); - } - - if ($date_field = $request->input('date_field')) { - $categories->dateBetween($date_field, $request->input('date_from'), $request->input('date_to')); - } - - if ($request->download) { - return (new CategoriesExport($categories))->download('categories.xlsx'); - } - - $categories = $categories->paginate($per_page) - ->appends($request->except('page')); - - return view('admin.categories.index', compact('categories', 'title', 'per_page', 'search')); - } - - /** - * Show the form for creating a new resource. - */ - public function create(Request $request) - { - return view('admin.categories.create'); - } - - /** - * Store a newly created resource in storage. - */ - public function store(CategoriesRequest $request) - { - $category = new Category($request->validated()); - - $category->save(); - - $this->flashSuccessMessage(); - - return redirect()->action([CategoriesController::class, 'edit'], $category); - } - - /** - * Display the specified resource. - */ - public function show(Category $category) - { - return view('admin.categories.show', compact('category')); - } - - /** - * Show the form for editing the specified resource. - */ - public function edit(Category $category) - { - return view('admin.categories.edit', compact('category')); - } - - /** - * Update the specified resource in storage. - */ - public function update(CategoriesRequest $request, Category $category) - { - $category->fill($request->validated()); - - $category->save(); - - $this->flashSuccessMessage(); - - return redirect()->action([CategoriesController::class, 'edit'], $category); - } - - /** - * Remove the specified resource from storage. - */ - public function destroy(Category $category, Request $request) - { - $category->delete(); - - if ($request->expectsJson()) { - return response()->json(true); - } - - return redirect()->action([CategoriesController::class, 'index']); - } - - /** - * Perform bulk action on the resource - */ - public function bulk(Request $request) - { - $this->authorize('viewAny', Category::class); - - $this->validate($request, [ - 'action' => 'required|in:delete', - 'categories' => 'required|array', - 'categories.*' => 'exists:categories,id', - ]); - - $action = $request->input('action'); - $ids = $request->input('categories', []); - - switch ($action) { - case 'delete': - // make sure allowed to delete - $this->authorize('delete_categories'); - - Category::whereIn('id', $ids) - ->get() - ->each(function (Category $category) { - $category->delete(); - }); - break; - } - - $this->flashSuccessMessage(); - - return $this->redirect($request, action([CategoriesController::class, 'index'])); - } -} diff --git a/tests/stubs/Controllers/Customer/Auth/ConfirmPasswordController.php b/tests/stubs/Controllers/Customer/Auth/ConfirmPasswordController.php deleted file mode 100644 index d961270..0000000 --- a/tests/stubs/Controllers/Customer/Auth/ConfirmPasswordController.php +++ /dev/null @@ -1,20 +0,0 @@ -middleware('guest:web_customer'); - } - - #[\Override] - public function getBroker(): PasswordBroker - { - return Password::broker('customers'); - } - - #[\Override] - public function getPasswordResetForm(): View - { - return view('customer.auth.passwords.email'); - } -} diff --git a/tests/stubs/Controllers/Customer/Auth/LoginController.php b/tests/stubs/Controllers/Customer/Auth/LoginController.php deleted file mode 100644 index 0c4faf7..0000000 --- a/tests/stubs/Controllers/Customer/Auth/LoginController.php +++ /dev/null @@ -1,19 +0,0 @@ - ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('customers', 'email')], - 'password' => ['required', 'string', Password::min(8), 'confirmed'], - 'designation' => ['required', Rule::unique('customers', 'designation'), 'string', 'max:255'], - 'address' => ['required', 'string', 'max:255'], - 'expire_at' => ['required', 'date'], - 'agreement' => ['accepted'], - ]; - - if (! app()->runningUnitTests()) { - $rules[recaptchaFieldName()] = recaptchaRuleName(); - } - - return Validator::make($data, $rules); - } - - /** - * Create a new user instance after a valid registration. - */ - public function create(array $data) - { - $class = $this->userClass(); - - $customer = new $class(); - - $customer->name = $data['name']; - $customer->email = $data['email']; - $customer->password = $data['password']; - $customer->status = UserStatuses::APPROVED; - $customer->designation = $data['designation']; - $customer->address = $data['address']; - $customer->expire_at = $data['expire_at']; - - $customer->save(); - - return $customer; - } -} diff --git a/tests/stubs/Controllers/Customer/Auth/ResetPasswordController.php b/tests/stubs/Controllers/Customer/Auth/ResetPasswordController.php deleted file mode 100644 index 8075b54..0000000 --- a/tests/stubs/Controllers/Customer/Auth/ResetPasswordController.php +++ /dev/null @@ -1,25 +0,0 @@ -user(); - - return view('customer.account.edit', compact('customer')); - } - - /** - * Update the customer account - */ - public function update(CustomersRequest $request) - { - /** @var Customer $customer */ - $customer = $request->user(); - - if ($action = $request->input('action')) { - switch ($action) { - case 'update_password': - // update password - if ($password = $request->input('password')) { - $customer->password = $password; - $customer->save(); - - flash_push('alerts', [ - 'text' => __('Password changed successfully'), - 'type' => 'success', - 'title' => __('Success!'), - ]); - } - break; - } - } else { - // update the email - if (($new_email = $request->email) && $new_email != $customer->email) { - $new_email = $customer->requestEmailUpdate($new_email); - if ($new_email) { - flash_push('alerts', [ - 'text' => __('We\'ve sent a verification link to :new_email. Please verify the new email address to update it', compact('new_email')), - 'type' => 'success', - 'title' => __('New Email Verification Sent'), - ]); - } - } - - $customer->fill($request->validated()); - - if ($request->hasAny(['on_sale', 'sync_on_sale'])) { - $customer->on_sale = $request->input('on_sale', false); - } - - if ($request->has('category')) { - $customer->category()->associate($request->input('category')); - } - - $customer->save(); - - // update avatar - $customer->updateSingleMedia('avatar', $request); - - $this->flashSuccessMessage(); - } - - return $this->redirect($request, route('customer.account')); - } -} diff --git a/tests/stubs/Controllers/Customer/HomeController.php b/tests/stubs/Controllers/Customer/HomeController.php deleted file mode 100644 index d64aa4f..0000000 --- a/tests/stubs/Controllers/Customer/HomeController.php +++ /dev/null @@ -1,21 +0,0 @@ -user(); - - return view('customer.home.show', compact('customer')); - } -} diff --git a/tests/stubs/Controllers/CustomersController.php b/tests/stubs/Controllers/CustomersController.php deleted file mode 100644 index 9389c8e..0000000 --- a/tests/stubs/Controllers/CustomersController.php +++ /dev/null @@ -1,358 +0,0 @@ -authorizeResource(Customer::class); - } - - /** - * Initialize orderbys - */ - protected static function initOrderbys() - { - static::$orderbys = [ - 'id' => __('Id'), - 'name' => __('Name'), - 'email' => __('Email'), - 'created_at' => __('Created At'), - 'updated_at' => __('Updated At'), - 'designation' => __('Designation'), - 'address' => __('Address'), - 'expire_at' => __('Expire At'), - ]; - } - - /** - * Display a listing of the resource. - */ - public function index(Request $request, bool $trashed = false) - { - $title = $trashed ? __('All Deleted Customers') : __('All Customers'); - $orderby = $this->getOrderBy($request, 'created_at'); - $order = $this->getOrder($request, 'created_at', $orderby); - $per_page = $this->getPerPage($request); - - $customers = Customer::orderBy($orderby, $order); - - $search = null; - if ($search = $request->input('search')) { - $customers->search($search); - $title = __('Customers matching \':search\'', ['search' => $search]); - } - - if ($status = $request->input('status')) { - $customers->whereStatus($status); - } - - if ($request->filled('locked_out')) { - if ($request->input('locked_out')) { - $customers->lockedOut(); - } else { - $customers->notLockedOut(); - } - } - - if ($date_field = $request->input('date_field')) { - $customers->dateBetween($date_field, $request->input('date_from'), $request->input('date_to')); - } - - if ($trashed) { - $customers->onlyTrashed(); - } - - $customers->with('media', 'category'); - - if ($request->download) { - return (new CustomersExport($customers))->download('customers.xlsx'); - } - - $customers = $customers->paginate($per_page) - ->appends($request->except('page')); - - return view('admin.customers.index', compact('customers', 'title', 'per_page', 'search', 'trashed')); - } - - /** - * Show the form for creating a new resource. - */ - public function create(Request $request) - { - return view('admin.customers.create'); - } - - /** - * Store a newly created resource in storage. - */ - public function store(CustomersRequest $request) - { - $customer = new Customer($request->validated()); - $customer->email = $request->input('email'); - $customer->setEmailVerificationStatus($request->input('email_verified', false)); - - if ($request->hasAny(['on_sale', 'sync_on_sale'])) { - $customer->on_sale = $request->input('on_sale', false); - } - - if ($request->has('category')) { - $customer->category()->associate($request->input('category')); - } - - $password = $request->input('password'); - - if ($password) { - $customer->password = $password; - $customer->require_password_update = $request->input('require_password_update', false); - } - - if ($request->user()->can('approve', Customer::class)) { - $customer->approve(); - } else { - $customer->markAsPending(); - } - - $customer->save(); - - $customer->updateSingleMedia('avatar', $request); - - if ($password && $request->send_password) { - $customer->notify(new NewPasswordNotification($password)); - } - - $this->flashSuccessMessage(); - - return redirect()->action([CustomersController::class, 'edit'], $customer); - } - - /** - * Display the specified resource. - */ - public function show(Customer $customer) - { - return view('admin.customers.show', compact('customer')); - } - - /** - * Show the form for editing the specified resource. - */ - public function edit(Customer $customer) - { - return view('admin.customers.edit', compact('customer')); - } - - /** - * Update the specified resource in storage. - */ - public function update(CustomersRequest $request, Customer $customer) - { - if ($action = $request->input('action')) { - switch ($action) { - case 'resend_verification': - $this->authorize('resendVerification', $customer); - $customer->resendEmailVerification(); - - flash_push('alerts', [ - 'text' => __('Email verification resent successfully.'), - 'type' => 'success', - 'title' => __('Success!'), - ]); - break; - - case 'approve': - $this->authorize('approve', Customer::class); - $customer->approve(); - break; - - case 'ban': - $this->authorize('approve', Customer::class); - $customer->ban(); - break; - - case 'mark_pending': - $this->authorize('approve', Customer::class); - $customer->markAsPending(); - break; - - case 'update_password': - // update password - if ($password = $request->input('password')) { - $customer->password = $password; - - if ($request->input('require_password_update')) { - $customer->require_password_update = true; - } else { - $customer->require_password_update = false; - } - - $customer->resetLoginAttempts(); - - if ($password && $request->send_password) { - $customer->notify(new NewPasswordNotification($password)); - } - - flash_push('alerts', [ - 'text' => __('Password changed successfully'), - 'type' => 'success', - 'title' => __('Success!'), - ]); - } - break; - } - } else { - $customer->fill($request->validated()); - - if ($request->hasAny(['on_sale', 'sync_on_sale'])) { - $customer->on_sale = $request->input('on_sale', false); - } - - if ($request->has('category')) { - $customer->category()->associate($request->input('category')); - } - - if ($email = $request->input('email')) { - $customer->email = $email; - } - - if ($request->filled('email_verified')) { - $customer->setEmailVerificationStatus($request->email_verified); - } - - $customer->updateSingleMedia('avatar', $request); - } - - $customer->save(); - - $this->flashSuccessMessage(); - - return redirect()->action([CustomersController::class, 'edit'], $customer); - } - - /** - * Remove the specified resource from storage. - */ - public function destroy(Customer $customer, Request $request) - { - $customer->delete(); - - if ($request->expectsJson()) { - return response()->json(true); - } - - return redirect()->action([CustomersController::class, 'index']); - } - - /** - * Display a listing of the deleted resources. - */ - public function trash(Request $request) - { - $this->authorize('viewTrash', Customer::class); - - return $this->index($request, true); - } - - /** - * Force delete the resource - */ - public function forceDelete(Customer $customer, Request $request) - { - // make sure it is trashed - if (! $customer->trashed()) { - abort(404); - } - - $this->authorize('forceDelete', $customer); - - $customer->forceDelete(); - - if ($request->expectsJson()) { - return response()->json(true); - } - - return redirect()->action([CustomersController::class, 'trash']); - } - - /** - * Restore deleted resource - */ - public function restore(Customer $customer, Request $request) - { - // make sure it is trashed - if (! $customer->trashed()) { - abort(404); - } - - $this->authorize('restore', $customer); - - $customer->restore(); - - if ($request->expectsJson()) { - return response()->json(true); - } - - return redirect()->action([CustomersController::class, 'trash']); - } - - /** - * Perform bulk action on the resource - */ - public function bulk(Request $request) - { - $this->authorize('viewAny', Customer::class); - - $this->validate($request, [ - 'action' => 'required|in:delete,approve,ban', - 'customers' => 'required|array', - 'customers.*' => 'exists:customers,id', - ]); - - $action = $request->input('action'); - $ids = $request->input('customers', []); - - switch ($action) { - case 'delete': - // make sure allowed to delete - $this->authorize('delete_customers'); - - Customer::whereIn('id', $ids) - ->get() - ->each(function (Customer $customer) { - $customer->delete(); - }); - break; - - case 'ban': - case 'approve': - // make sure allowed to approve - $this->authorize('approve', Customer::class); - - Customer::whereIn('id', $ids) - ->get() - ->each(function (Customer $customer) use ($action) { - $customer->{$action}(); - $customer->save(); - }); - break; - } - - $this->flashSuccessMessage(); - - return $this->redirect($request, action([CustomersController::class, 'index'])); - } -} diff --git a/tests/stubs/Controllers/OrdersController.php b/tests/stubs/Controllers/OrdersController.php deleted file mode 100644 index 4888a23..0000000 --- a/tests/stubs/Controllers/OrdersController.php +++ /dev/null @@ -1,186 +0,0 @@ -authorizeResource(Order::class); - } - - /** - * Initialize orderbys - */ - protected static function initOrderbys() - { - static::$orderbys = [ - 'id' => __('Id'), - 'created_at' => __('Created At'), - 'updated_at' => __('Updated At'), - 'order_no' => __('Order No'), - ]; - } - - /** - * Display a listing of the resource. - */ - public function index(Request $request) - { - $title = __('All Orders'); - $orderby = $this->getOrderBy($request, 'created_at'); - $order = $this->getOrder($request, 'created_at', $orderby); - $per_page = $this->getPerPage($request); - - $orders = Order::orderBy($orderby, $order); - - $search = null; - if ($search = $request->input('search')) { - $orders->search($search); - $title = __('Orders matching \':search\'', ['search' => $search]); - } - - if ($date_field = $request->input('date_field')) { - $orders->dateBetween($date_field, $request->input('date_from'), $request->input('date_to')); - } - - $orders->with('category', 'productSlug'); - - if ($request->download) { - return (new OrdersExport($orders))->download('orders.xlsx'); - } - - $orders = $orders->paginate($per_page) - ->appends($request->except('page')); - - return view('admin.orders.index', compact('orders', 'title', 'per_page', 'search')); - } - - /** - * Show the form for creating a new resource. - */ - public function create(Request $request) - { - return view('admin.orders.create'); - } - - /** - * Store a newly created resource in storage. - */ - public function store(OrdersRequest $request) - { - $order = new Order($request->validated()); - - if ($request->has('category')) { - $order->category()->associate($request->input('category')); - } - - if ($request->has('product_slug')) { - $order->productSlug()->associate($request->input('product_slug')); - } - - $order->save(); - - $this->flashSuccessMessage(); - - return redirect()->action([OrdersController::class, 'edit'], $order); - } - - /** - * Display the specified resource. - */ - public function show(Order $order) - { - return view('admin.orders.show', compact('order')); - } - - /** - * Show the form for editing the specified resource. - */ - public function edit(Order $order) - { - return view('admin.orders.edit', compact('order')); - } - - /** - * Update the specified resource in storage. - */ - public function update(OrdersRequest $request, Order $order) - { - $order->fill($request->validated()); - - if ($request->has('category')) { - $order->category()->associate($request->input('category')); - } - - if ($request->has('product_slug')) { - $order->productSlug()->associate($request->input('product_slug')); - } - - $order->save(); - - $this->flashSuccessMessage(); - - return redirect()->action([OrdersController::class, 'edit'], $order); - } - - /** - * Remove the specified resource from storage. - */ - public function destroy(Order $order, Request $request) - { - $order->delete(); - - if ($request->expectsJson()) { - return response()->json(true); - } - - return redirect()->action([OrdersController::class, 'index']); - } - - /** - * Perform bulk action on the resource - */ - public function bulk(Request $request) - { - $this->authorize('viewAny', Order::class); - - $this->validate($request, [ - 'action' => 'required|in:delete', - 'orders' => 'required|array', - 'orders.*' => 'exists:orders,id', - ]); - - $action = $request->input('action'); - $ids = $request->input('orders', []); - - switch ($action) { - case 'delete': - // make sure allowed to delete - $this->authorize('delete_orders'); - - Order::whereIn('id', $ids) - ->get() - ->each(function (Order $order) { - $order->delete(); - }); - break; - } - - $this->flashSuccessMessage(); - - return $this->redirect($request, action([OrdersController::class, 'index'])); - } -} diff --git a/tests/stubs/Controllers/ProductsController.php b/tests/stubs/Controllers/ProductsController.php deleted file mode 100644 index f9c99b3..0000000 --- a/tests/stubs/Controllers/ProductsController.php +++ /dev/null @@ -1,251 +0,0 @@ -authorizeResource(Product::class); - } - - /** - * Initialize orderbys - */ - protected static function initOrderbys() - { - static::$orderbys = [ - 'id' => __('Id'), - 'created_at' => __('Created At'), - 'updated_at' => __('Updated At'), - 'name' => __('Name'), - 'address' => __('Address'), - 'slug' => __('Slug'), - 'price' => __('Price'), - 'stock' => __('Stock'), - 'published_at' => __('Published At'), - 'expire_at' => __('Expire At'), - 'released_on' => __('Released On'), - 'sale_time' => __('Sale Time'), - 'manufactured_year' => __('Manufactured Year'), - ]; - } - - /** - * Display a listing of the resource. - */ - public function index(Request $request, bool $trashed = false) - { - $title = $trashed ? __('All Deleted Products') : __('All Products'); - $orderby = $this->getOrderBy($request, 'created_at'); - $order = $this->getOrder($request, 'created_at', $orderby); - $per_page = $this->getPerPage($request); - - $products = Product::orderBy($orderby, $order); - - $search = null; - if ($search = $request->input('search')) { - $products->search($search); - $title = __('Products matching \':search\'', ['search' => $search]); - } - - if ($date_field = $request->input('date_field')) { - $products->dateBetween($date_field, $request->input('date_from'), $request->input('date_to')); - } - - if ($trashed) { - $products->onlyTrashed(); - } - - $products->with('category'); - - if ($request->download) { - return (new ProductsExport($products))->download('products.xlsx'); - } - - $products = $products->paginate($per_page) - ->appends($request->except('page')); - - return view('admin.products.index', compact('products', 'title', 'per_page', 'search', 'trashed')); - } - - /** - * Show the form for creating a new resource. - */ - public function create(Request $request) - { - return view('admin.products.create'); - } - - /** - * Store a newly created resource in storage. - */ - public function store(ProductsRequest $request) - { - $product = new Product($request->validated()); - - if ($request->hasAny(['on_sale', 'sync_on_sale'])) { - $product->on_sale = $request->input('on_sale', false); - } - - if ($request->has('category')) { - $product->category()->associate($request->input('category')); - } - - $product->save(); - - $this->flashSuccessMessage(); - - return redirect()->action([ProductsController::class, 'edit'], $product); - } - - /** - * Display the specified resource. - */ - public function show(Product $product) - { - return view('admin.products.show', compact('product')); - } - - /** - * Show the form for editing the specified resource. - */ - public function edit(Product $product) - { - return view('admin.products.edit', compact('product')); - } - - /** - * Update the specified resource in storage. - */ - public function update(ProductsRequest $request, Product $product) - { - $product->fill($request->validated()); - - if ($request->hasAny(['on_sale', 'sync_on_sale'])) { - $product->on_sale = $request->input('on_sale', false); - } - - if ($request->has('category')) { - $product->category()->associate($request->input('category')); - } - - $product->save(); - - $this->flashSuccessMessage(); - - return redirect()->action([ProductsController::class, 'edit'], $product); - } - - /** - * Remove the specified resource from storage. - */ - public function destroy(Product $product, Request $request) - { - $product->delete(); - - if ($request->expectsJson()) { - return response()->json(true); - } - - return redirect()->action([ProductsController::class, 'index']); - } - - /** - * Display a listing of the deleted resources. - */ - public function trash(Request $request) - { - $this->authorize('viewTrash', Product::class); - - return $this->index($request, true); - } - - /** - * Force delete the resource - */ - public function forceDelete(Product $product, Request $request) - { - // make sure it is trashed - if (! $product->trashed()) { - abort(404); - } - - $this->authorize('forceDelete', $product); - - $product->forceDelete(); - - if ($request->expectsJson()) { - return response()->json(true); - } - - return redirect()->action([ProductsController::class, 'trash']); - } - - /** - * Restore deleted resource - */ - public function restore(Product $product, Request $request) - { - // make sure it is trashed - if (! $product->trashed()) { - abort(404); - } - - $this->authorize('restore', $product); - - $product->restore(); - - if ($request->expectsJson()) { - return response()->json(true); - } - - return redirect()->action([ProductsController::class, 'trash']); - } - - /** - * Perform bulk action on the resource - */ - public function bulk(Request $request) - { - $this->authorize('viewAny', Product::class); - - $this->validate($request, [ - 'action' => 'required|in:delete', - 'products' => 'required|array', - 'products.*' => 'exists:products,id', - ]); - - $action = $request->input('action'); - $ids = $request->input('products', []); - - switch ($action) { - case 'delete': - // make sure allowed to delete - $this->authorize('delete_products'); - - Product::whereIn('id', $ids) - ->get() - ->each(function (Product $product) { - $product->delete(); - }); - break; - } - - $this->flashSuccessMessage(); - - return $this->redirect($request, action([ProductsController::class, 'index'])); - } -} diff --git a/tests/stubs/Controllers/_categoryForeignKey.stub b/tests/stubs/Controllers/_categoryForeignKey.stub deleted file mode 100644 index d88e45f..0000000 --- a/tests/stubs/Controllers/_categoryForeignKey.stub +++ /dev/null @@ -1,3 +0,0 @@ - if ($request->has('category')) { - $product->category()->associate($request->input('category')); - } diff --git a/tests/stubs/Controllers/_onSaleBoolean.stub b/tests/stubs/Controllers/_onSaleBoolean.stub deleted file mode 100644 index 0425c2a..0000000 --- a/tests/stubs/Controllers/_onSaleBoolean.stub +++ /dev/null @@ -1,3 +0,0 @@ - if ($request->hasAny(['on_sale', 'sync_on_sale'])) { - $product->on_sale = $request->input('on_sale', false); - } diff --git a/tests/stubs/Controllers/_onSaleBooleanIndented.stub b/tests/stubs/Controllers/_onSaleBooleanIndented.stub deleted file mode 100644 index 8548b47..0000000 --- a/tests/stubs/Controllers/_onSaleBooleanIndented.stub +++ /dev/null @@ -1,3 +0,0 @@ - if ($request->hasAny(['on_sale', 'sync_on_sale'])) { - $product->on_sale = $request->input('on_sale', false); - } diff --git a/tests/stubs/Controllers/_orderBy.stub b/tests/stubs/Controllers/_orderBy.stub deleted file mode 100644 index 451c80f..0000000 --- a/tests/stubs/Controllers/_orderBy.stub +++ /dev/null @@ -1 +0,0 @@ - 'created_at' => __('Created At'), diff --git a/tests/stubs/Controllers/_ordersEagerLoads.stub b/tests/stubs/Controllers/_ordersEagerLoads.stub deleted file mode 100644 index 91699fb..0000000 --- a/tests/stubs/Controllers/_ordersEagerLoads.stub +++ /dev/null @@ -1,2 +0,0 @@ - - $orders->with('category', 'productSlug'); diff --git a/tests/stubs/Exports/AllForeignsExport.php b/tests/stubs/Exports/AllForeignsExport.php deleted file mode 100644 index ae7557c..0000000 --- a/tests/stubs/Exports/AllForeignsExport.php +++ /dev/null @@ -1,25 +0,0 @@ -icon('zmdi-view-quilt') - ->route('admin.home'), - - MenuItem::make(__('Users')) - ->controller(\App\Http\Controllers\Admin\UsersController::class) - ->can('viewAny', \App\Models\User::class) - ->icon('zmdi-accounts') - ->count(\App\Models\User::userVisible()->pending(), 'approve_users'), - - MenuItem::make(__('Roles')) - ->controller(\App\Http\Controllers\Admin\RolesController::class) - ->can('viewAny', \Javaabu\Permissions\Models\Role::class) - ->icon('zmdi-folder-person'), - - MenuItem::make(__('Activity Logs')) - ->controller(\App\Http\Controllers\Admin\LogsController::class) - ->can('viewAny', \Javaabu\Activitylog\Models\Activity::class) - ->icon('zmdi-assignment'), - - MenuItem::make(__('OAuth2 Clients')) - ->controller(\App\Http\Controllers\Admin\OAuthClientsController::class) - ->can('viewAny', \Laravel\Passport\Client::class) - ->icon('zmdi-key'), - - MenuItem::make(__('Settings')) - ->controller(\App\Http\Controllers\Admin\SettingsController::class) - ->permissions('edit_settings') - ->icon('zmdi-settings'), - ]; - } -} diff --git a/tests/stubs/Menus/ModelsAdminSidebar.php b/tests/stubs/Menus/ModelsAdminSidebar.php deleted file mode 100644 index 1400eaa..0000000 --- a/tests/stubs/Menus/ModelsAdminSidebar.php +++ /dev/null @@ -1,57 +0,0 @@ -icon('zmdi-view-quilt') - ->route('admin.home'), - - MenuItem::make(__('Products')) - ->controller(\App\Http\Controllers\Admin\ProductsController::class) - ->can('viewAny', \App\Models\Product::class) - ->icon('zmdi-shopping-cart'), - - MenuItem::make(__('Categories')) - ->controller(\App\Http\Controllers\Admin\CategoriesController::class) - ->can('viewAny', \App\Models\Category::class) - ->icon('zmdi-folder'), - - MenuItem::make(__('Users')) - ->controller(\App\Http\Controllers\Admin\UsersController::class) - ->can('viewAny', \App\Models\User::class) - ->icon('zmdi-accounts') - ->count(\App\Models\User::userVisible()->pending(), 'approve_users'), - - MenuItem::make(__('Roles')) - ->controller(\App\Http\Controllers\Admin\RolesController::class) - ->can('viewAny', \Javaabu\Permissions\Models\Role::class) - ->icon('zmdi-folder-person'), - - MenuItem::make(__('Activity Logs')) - ->controller(\App\Http\Controllers\Admin\LogsController::class) - ->can('viewAny', \Javaabu\Activitylog\Models\Activity::class) - ->icon('zmdi-assignment'), - - MenuItem::make(__('OAuth2 Clients')) - ->controller(\App\Http\Controllers\Admin\OAuthClientsController::class) - ->can('viewAny', \Laravel\Passport\Client::class) - ->icon('zmdi-key'), - - MenuItem::make(__('Settings')) - ->controller(\App\Http\Controllers\Admin\SettingsController::class) - ->permissions('edit_settings') - ->icon('zmdi-settings'), - ]; - } -} diff --git a/tests/stubs/Menus/_categoriesSidebar.stub b/tests/stubs/Menus/_categoriesSidebar.stub deleted file mode 100644 index 6dab88b..0000000 --- a/tests/stubs/Menus/_categoriesSidebar.stub +++ /dev/null @@ -1,4 +0,0 @@ - MenuItem::make(__('Categories')) - ->controller(\App\Http\Controllers\Admin\CategoriesController::class) - ->can('viewAny', \App\Models\Category::class) - ->icon('zmdi-folder'), diff --git a/tests/stubs/Models/Category.php b/tests/stubs/Models/Category.php deleted file mode 100644 index d0ff569..0000000 --- a/tests/stubs/Models/Category.php +++ /dev/null @@ -1,71 +0,0 @@ - - */ - protected function casts(): array - { - return [ - 'name' => 'string', - 'slug' => 'string', - ]; - } - - /** - * Get the admin url attribute - */ - public function getAdminUrlAttribute(): string - { - return route('admin.categories.show', $this); - } -} diff --git a/tests/stubs/Models/Customer.php b/tests/stubs/Models/Customer.php deleted file mode 100644 index 3bfb9ce..0000000 --- a/tests/stubs/Models/Customer.php +++ /dev/null @@ -1,141 +0,0 @@ - - */ - protected function casts(): array - { - $casts = parent::casts(); - - return $casts + [ - 'designation' => 'string', - 'address' => 'string', - 'on_sale' => 'boolean', - 'expire_at' => 'datetime', - ]; - } - - /** - * Convert dates to Carbon - */ - public function setExpireAtAttribute($value) - { - return $this->attributes['expire_at'] = $value ? Carbon::parse($value) : now(); - } - - /** - * Get the admin url attribute - */ - public function getAdminUrlAttribute(): string - { - return route('admin.customers.show', $this); - } - - public function guardName(): string - { - return 'web_customer'; - } - - #[\Override] - public function passwordUpdateUrl(): string - { - return route('customer.password.new-password'); - } - - #[\Override] - public function homeUrl(): string - { - return route('customer.home'); - } - - #[\Override] - public function loginUrl(): string - { - return route('customer.login'); - } - - #[\Override] - public function getRouteForPasswordReset(): string - { - return 'customer.password.reset'; - } - - #[\Override] - public function getRouteForEmailVerification(): string - { - return 'customer.verification.verify'; - } - - #[\Override] - public function inactiveNoticeUrl(): string - { - return route('customer.verification.notice'); - } - - /** - * User visible scope - * - * @param $query - * @return mixed - */ - public function scopeUserVisible($query) - { - // try customer - $customer = auth()->user() instanceof Customer ? - auth()->user() : - auth()->guard('web_customer')->user(); - - if ($customer) { - return $query->where($this->getTable().'.id', $customer->id); - } - - // then try admin - $admin = auth()->user() instanceof User ? - auth()->user() : - auth()->guard('web_admin')->user(); - - if ($admin) { - if ($admin->can('viewAny', static::class)) { - // can view all - return $query; - } - } - - // can't view any - return $query->where($this->getTable().'.id', -1); - } - - /** - * A customer belongs to a category - */ - public function category(): BelongsTo - { - return $this->belongsTo(Category::class); - } -} diff --git a/tests/stubs/Models/Order.php b/tests/stubs/Models/Order.php deleted file mode 100644 index 7e75816..0000000 --- a/tests/stubs/Models/Order.php +++ /dev/null @@ -1,98 +0,0 @@ - - */ - protected function casts(): array - { - return [ - 'order_no' => 'string', - 'status' => OrderStatuses::class, - ]; - } - - /** - * Get the admin url attribute - */ - public function getAdminUrlAttribute(): string - { - return route('admin.orders.show', $this); - } - - /** - * Get the name for the admin link - * - * @return string - */ - public function getAdminLinkNameAttribute(): string - { - return $this->order_no; - } - - /** - * A order belongs to a category - */ - public function category(): BelongsTo - { - return $this->belongsTo(Category::class); - } - - /** - * A order belongs to a product - */ - public function productSlug(): BelongsTo - { - return $this->belongsTo(Product::class, 'product_slug', 'slug'); - } -} diff --git a/tests/stubs/Models/Product.php b/tests/stubs/Models/Product.php deleted file mode 100644 index ca52e7a..0000000 --- a/tests/stubs/Models/Product.php +++ /dev/null @@ -1,139 +0,0 @@ - - */ - protected function casts(): array - { - return [ - 'name' => 'string', - 'address' => 'string', - 'slug' => 'string', - 'description' => 'string', - 'price' => 'decimal:2', - 'stock' => 'integer', - 'on_sale' => 'boolean', - 'features' => 'array', - 'published_at' => 'datetime', - 'expire_at' => 'datetime', - 'released_on' => 'date', - 'sale_time' => 'datetime', - 'manufactured_year' => 'integer', - ]; - } - - /** - * Convert dates to Carbon - */ - public function setPublishedAtAttribute($value) - { - return $this->attributes['published_at'] = $value ? Carbon::parse($value) : now(); - } - - /** - * Convert dates to Carbon - */ - public function setExpireAtAttribute($value) - { - return $this->attributes['expire_at'] = $value ? Carbon::parse($value) : now(); - } - - /** - * Convert dates to Carbon - */ - public function setReleasedOnAttribute($value) - { - return $this->attributes['released_on'] = $value ? Carbon::parse($value) : now(); - } - - /** - * Convert dates to Carbon - */ - public function setSaleTimeAttribute($value) - { - return $this->attributes['sale_time'] = $value ? Carbon::parse($value) : now(); - } - - /** - * Get the admin url attribute - */ - public function getAdminUrlAttribute(): string - { - return route('admin.products.show', $this); - } - - /** - * A product belongs to a category - */ - public function category(): BelongsTo - { - return $this->belongsTo(Category::class); - } -} diff --git a/tests/stubs/Models/PublicUser.php b/tests/stubs/Models/PublicUser.php deleted file mode 100644 index 3fa00c0..0000000 --- a/tests/stubs/Models/PublicUser.php +++ /dev/null @@ -1,102 +0,0 @@ -user() instanceof PublicUser ? - auth()->user() : - auth()->guard('web_public_user')->user(); - - if ($public_user) { - return $query->where($this->getTable().'.id', $public_user->id); - } - - // then try admin - $admin = auth()->user() instanceof User ? - auth()->user() : - auth()->guard('web_admin')->user(); - - if ($admin) { - if ($admin->can('viewAny', static::class)) { - // can view all - return $query; - } - } - - // can't view any - return $query->where($this->getTable().'.id', -1); - } -} diff --git a/tests/stubs/Models/_categoryForeignKey.stub b/tests/stubs/Models/_categoryForeignKey.stub deleted file mode 100644 index 7b75027..0000000 --- a/tests/stubs/Models/_categoryForeignKey.stub +++ /dev/null @@ -1,7 +0,0 @@ - /** - * A product belongs to a category - */ - public function category(): BelongsTo - { - return $this->belongsTo(Category::class); - } diff --git a/tests/stubs/Models/_dateMutator.stub b/tests/stubs/Models/_dateMutator.stub deleted file mode 100644 index 56f934c..0000000 --- a/tests/stubs/Models/_dateMutator.stub +++ /dev/null @@ -1,7 +0,0 @@ - /** - * Convert dates to Carbon - */ - public function setPublishedAtAttribute($value) - { - return $this->attributes['published_at'] = $value ? Carbon::parse($value) : now(); - } diff --git a/tests/stubs/Models/_nullableDateMutator.stub b/tests/stubs/Models/_nullableDateMutator.stub deleted file mode 100644 index 377215b..0000000 --- a/tests/stubs/Models/_nullableDateMutator.stub +++ /dev/null @@ -1,7 +0,0 @@ - /** - * Convert dates to Carbon - */ - public function setPublishedAtAttribute($value) - { - return $this->attributes['published_at'] = $value ? Carbon::parse($value) : null; - } diff --git a/tests/stubs/MultipleNameCases.php b/tests/stubs/MultipleNameCases.php deleted file mode 100644 index f1b8465..0000000 --- a/tests/stubs/MultipleNameCases.php +++ /dev/null @@ -1,19 +0,0 @@ -can('view_categories'); - } - - /** - * Determine whether the user can view the model. - */ - public function view(User $user, Category $category): bool - { - return $user->can('view_categories'); - } - - /** - * Determine whether the user can create models. - */ - public function create(User $user): bool - { - return $user->can('edit_categories'); - } - - /** - * Determine whether the user can update the model. - */ - public function update(User $user, Category $category): bool - { - return $user->can('edit_categories'); - } - - /** - * Determine whether the user can delete the model. - */ - public function delete(User $user, Category $category): bool - { - return $user->can('delete_categories'); - } - - /** - * Determine whether the user can view the model logs. - */ - public function viewLogs(User $user, Category $category): bool - { - return $user->can('viewAny', Activity::class) && $this->update($user, $category); - } -} diff --git a/tests/stubs/Policies/CustomerPolicy.php b/tests/stubs/Policies/CustomerPolicy.php deleted file mode 100644 index c1c3473..0000000 --- a/tests/stubs/Policies/CustomerPolicy.php +++ /dev/null @@ -1,99 +0,0 @@ -can('view_customers'); - } - - /** - * Determine whether the user can view the model. - */ - public function view(User $user, Customer $customer): bool - { - return $user->can('view_customers'); - } - - /** - * Determine whether the user can create models. - */ - public function create(User $user): bool - { - return $user->can('edit_customers'); - } - - /** - * Determine whether the user can update the model. - */ - public function update(User $user, Customer $customer): bool - { - return $user->can('edit_customers'); - } - - /** - * Determine whether the user can delete the model. - */ - public function delete(User $user, Customer $customer): bool - { - return $user->can('delete_customers'); - } - - /** - * Determine whether the user can view trashed models. - */ - public function viewTrash(User $user): bool - { - return $user->can('delete_customers') || $user->can('force_delete_customers'); - } - - /** - * Determine whether the user can restore the model. - */ - public function restore(User $user, Customer $customer): bool - { - return $user->can('delete_customers') || $user->can('force_delete_customers'); - } - - /** - * Determine whether the user can permanently delete the model. - */ - public function forceDelete(User $user, Customer $customer): bool - { - return $user->can('force_delete_customers'); - } - - /** - * Determine whether the user can view the model logs. - */ - public function viewLogs(User $user, Customer $customer): bool - { - return $user->can('viewAny', Activity::class) && $this->update($user, $customer); - } - - /** - * Determine whether the user can approve customers. - */ - public function approve(User $user): bool - { - return $user->can('approve_customers') && $this->create($user); - } - - /** - * Determine whether the user can resend the verification for the customer. - */ - public function resendVerification(User $user, Customer $customer): bool - { - return $customer->needsEmailVerification() && $this->update($user, $customer); - } -} diff --git a/tests/stubs/Policies/ProductPolicy.php b/tests/stubs/Policies/ProductPolicy.php deleted file mode 100644 index bde7d50..0000000 --- a/tests/stubs/Policies/ProductPolicy.php +++ /dev/null @@ -1,83 +0,0 @@ -can('view_products'); - } - - /** - * Determine whether the user can view the model. - */ - public function view(User $user, Product $product): bool - { - return $user->can('view_products'); - } - - /** - * Determine whether the user can create models. - */ - public function create(User $user): bool - { - return $user->can('edit_products'); - } - - /** - * Determine whether the user can update the model. - */ - public function update(User $user, Product $product): bool - { - return $user->can('edit_products'); - } - - /** - * Determine whether the user can delete the model. - */ - public function delete(User $user, Product $product): bool - { - return $user->can('delete_products'); - } - - /** - * Determine whether the user can view trashed models. - */ - public function viewTrash(User $user): bool - { - return $user->can('delete_products') || $user->can('force_delete_products'); - } - - /** - * Determine whether the user can restore the model. - */ - public function restore(User $user, Product $product): bool - { - return $user->can('delete_products') || $user->can('force_delete_products'); - } - - /** - * Determine whether the user can permanently delete the model. - */ - public function forceDelete(User $user, Product $product): bool - { - return $user->can('force_delete_products'); - } - - /** - * Determine whether the user can view the model logs. - */ - public function viewLogs(User $user, Product $product): bool - { - return $user->can('viewAny', Activity::class) && $this->update($user, $product); - } -} diff --git a/tests/stubs/Providers/AuthModelsAppServiceProvider.php b/tests/stubs/Providers/AuthModelsAppServiceProvider.php deleted file mode 100644 index 421ef38..0000000 --- a/tests/stubs/Providers/AuthModelsAppServiceProvider.php +++ /dev/null @@ -1,46 +0,0 @@ - \App\Models\Customer::class, - 'public_user' => \App\Models\PublicUser::class, - 'user' => \App\Models\User::class, - 'oauth_client' => \Laravel\Passport\Client::class, - 'role' => \Javaabu\Permissions\Models\Role::class, - ]); - - SubjectTypes::register([ - \App\Models\Customer::class, - \App\Models\PublicUser::class, - \App\Models\User::class, - \Javaabu\Permissions\Models\Role::class, - ]); - - CauserTypes::register([ - \App\Models\Customer::class, - \App\Models\PublicUser::class, - \App\Models\User::class, - ]); - } -} diff --git a/tests/stubs/Providers/ModelsAppServiceProvider.php b/tests/stubs/Providers/ModelsAppServiceProvider.php deleted file mode 100644 index 8ad6914..0000000 --- a/tests/stubs/Providers/ModelsAppServiceProvider.php +++ /dev/null @@ -1,44 +0,0 @@ - \App\Models\Product::class, - 'category' => \App\Models\Category::class, - 'user' => \App\Models\User::class, - 'oauth_client' => \Laravel\Passport\Client::class, - 'role' => \Javaabu\Permissions\Models\Role::class, - ]); - - SubjectTypes::register([ - \App\Models\Product::class, - \App\Models\Category::class, - \App\Models\User::class, - \Javaabu\Permissions\Models\Role::class, - ]); - - CauserTypes::register([ - \App\Models\User::class, - ]); - } -} diff --git a/tests/stubs/Providers/SkeletonAppServiceProvider.php b/tests/stubs/Providers/SkeletonAppServiceProvider.php deleted file mode 100644 index dca68a1..0000000 --- a/tests/stubs/Providers/SkeletonAppServiceProvider.php +++ /dev/null @@ -1,40 +0,0 @@ - \App\Models\User::class, - 'oauth_client' => \Laravel\Passport\Client::class, - 'role' => \Javaabu\Permissions\Models\Role::class, - ]); - - SubjectTypes::register([ - \App\Models\User::class, - \Javaabu\Permissions\Models\Role::class, - ]); - - CauserTypes::register([ - \App\Models\User::class, - ]); - } -} diff --git a/tests/stubs/Requests/CategoriesRequest.php b/tests/stubs/Requests/CategoriesRequest.php deleted file mode 100644 index f4d7883..0000000 --- a/tests/stubs/Requests/CategoriesRequest.php +++ /dev/null @@ -1,46 +0,0 @@ -|string> - */ - public function rules(): array - { - $rules = [ - 'name' => ['string', 'max:255'], - 'slug' => ['string', 'max:255'], - ]; - - $unique_name = Rule::unique('categories', 'name'); - $unique_slug = Rule::unique('categories', 'slug'); - - if ($category = $this->route('category')) { - $unique_name->ignore($category->getKey()); - $unique_slug->ignore($category->getKey()); - } else { - $rules['name'][] = 'required'; - $rules['slug'][] = 'required'; - } - - $rules['name'][] = $unique_name; - $rules['slug'][] = $unique_slug; - - return $rules; - } -} diff --git a/tests/stubs/Requests/CustomersRequest.php b/tests/stubs/Requests/CustomersRequest.php deleted file mode 100644 index a6b2db5..0000000 --- a/tests/stubs/Requests/CustomersRequest.php +++ /dev/null @@ -1,76 +0,0 @@ -user() instanceof Customer) { - if ($customer = $this->getRouteUser()) { - return $customer->id == $this->user()->id; - } else { - return if_route_pattern(['customer.*', 'api.customer.update']); - } - } - - return false; - } - - /** - * Get the route user - */ - protected function getRouteUser(): ?\Javaabu\Auth\User - { - return $this->route('customer'); - } - - /** - * Get the validation rules that apply to the request. - * - * @return array|string> - */ - public function rules(): array - { - $rules = $this->baseRules(); - - $rules += [ - 'designation' => ['string', 'max:255'], - 'address' => ['string', 'max:255'], - 'on_sale' => ['boolean'], - 'expire_at' => ['date'], - 'category' => ['nullable', 'exists:categories,id'], - ]; - - $unique_designation = Rule::unique('customers', 'designation'); - - if ($customer = $this->getUserBeingEdited()) { - $unique_designation->ignore($customer->getKey()); - } else { - $rules['designation'][] = 'required'; - $rules['address'][] = 'required'; - $rules['expire_at'][] = 'required'; - } - - $rules['designation'][] = $unique_designation; - - return $rules; - } -} diff --git a/tests/stubs/Requests/OrdersRequest.php b/tests/stubs/Requests/OrdersRequest.php deleted file mode 100644 index 8139f12..0000000 --- a/tests/stubs/Requests/OrdersRequest.php +++ /dev/null @@ -1,44 +0,0 @@ -|string> - */ - public function rules(): array - { - $rules = [ - 'order_no' => ['string', 'max:4'], - 'category' => ['exists:categories,id'], - 'product_slug' => ['exists:products,slug'], - 'status' => [Rule::enum(OrderStatuses::class)], - ]; - - if ($order = $this->route('order')) { - // - } else { - $rules['order_no'][] = 'required'; - $rules['category'][] = 'required'; - $rules['product_slug'][] = 'required'; - $rules['status'][] = 'required'; - } - - return $rules; - } -} diff --git a/tests/stubs/Requests/ProductsRequest.php b/tests/stubs/Requests/ProductsRequest.php deleted file mode 100644 index ff0efc0..0000000 --- a/tests/stubs/Requests/ProductsRequest.php +++ /dev/null @@ -1,66 +0,0 @@ -|string> - */ - public function rules(): array - { - $rules = [ - 'name' => ['string', 'max:255'], - 'address' => ['string', 'max:255'], - 'slug' => ['string', 'max:255'], - 'description' => ['nullable', 'string'], - 'price' => ['decimal:0,2', 'min:0', 'max:999999999999'], - 'stock' => ['integer', 'min:0', 'max:4294967295'], - 'on_sale' => ['boolean'], - 'features' => ['array'], - 'published_at' => ['date'], - 'expire_at' => ['date'], - 'released_on' => ['date'], - 'sale_time' => ['date'], - 'status' => ['in:draft,published'], - 'category' => ['nullable', 'exists:categories,id'], - 'manufactured_year' => ['integer', 'min:1900', 'max:2100'], - ]; - - $unique_slug = Rule::unique('products', 'slug'); - - if ($product = $this->route('product')) { - $unique_slug->ignore($product->getKey()); - } else { - $rules['name'][] = 'required'; - $rules['address'][] = 'required'; - $rules['slug'][] = 'required'; - $rules['price'][] = 'required'; - $rules['stock'][] = 'required'; - $rules['features'][] = 'required'; - $rules['published_at'][] = 'required'; - $rules['expire_at'][] = 'required'; - $rules['released_on'][] = 'required'; - $rules['sale_time'][] = 'required'; - $rules['status'][] = 'required'; - $rules['manufactured_year'][] = 'required'; - } - - $rules['slug'][] = $unique_slug; - - return $rules; - } -} diff --git a/tests/stubs/Requests/PublicUsersRequest.php b/tests/stubs/Requests/PublicUsersRequest.php deleted file mode 100644 index c8e0e31..0000000 --- a/tests/stubs/Requests/PublicUsersRequest.php +++ /dev/null @@ -1,55 +0,0 @@ -user() instanceof PublicUser) { - if ($public_user = $this->getRouteUser()) { - return $public_user->id == $this->user()->id; - } else { - return if_route_pattern(['portal.*', 'api.public-user.update']); - } - } - - return false; - } - - /** - * Get the route user - */ - protected function getRouteUser(): ?\Javaabu\Auth\User - { - return $this->route('public_user'); - } - - /** - * Get the validation rules that apply to the request. - * - * @return array|string> - */ - public function rules(): array - { - $rules = $this->baseRules(false, false); - - return $rules; - } -} diff --git a/tests/stubs/SingleNameCases.php b/tests/stubs/SingleNameCases.php deleted file mode 100644 index 7a375bb..0000000 --- a/tests/stubs/SingleNameCases.php +++ /dev/null @@ -1,19 +0,0 @@ - env('AUTH_GUARD', 'web_customer'), diff --git a/tests/stubs/config/auth/_default-passwords.stub b/tests/stubs/config/auth/_default-passwords.stub deleted file mode 100644 index 474954b..0000000 --- a/tests/stubs/config/auth/_default-passwords.stub +++ /dev/null @@ -1 +0,0 @@ -'passwords' => env('AUTH_PASSWORD_BROKER', 'customers'), diff --git a/tests/stubs/config/auth/_guards.stub b/tests/stubs/config/auth/_guards.stub deleted file mode 100644 index 369ffda..0000000 --- a/tests/stubs/config/auth/_guards.stub +++ /dev/null @@ -1,9 +0,0 @@ - 'web_customer' => [ - 'driver' => 'session', - 'provider' => 'customers', - ], - - 'api_customer' => [ - 'driver' => 'passport', - 'provider' => 'customers', - ], diff --git a/tests/stubs/config/auth/_passport-guards.stub b/tests/stubs/config/auth/_passport-guards.stub deleted file mode 100644 index 6f8a82e..0000000 --- a/tests/stubs/config/auth/_passport-guards.stub +++ /dev/null @@ -1 +0,0 @@ - 'api_customer', diff --git a/tests/stubs/config/auth/_passwords.stub b/tests/stubs/config/auth/_passwords.stub deleted file mode 100644 index 8324573..0000000 --- a/tests/stubs/config/auth/_passwords.stub +++ /dev/null @@ -1,6 +0,0 @@ - 'customers' => [ - 'provider' => 'customers', - 'table' => 'customer_password_reset_tokens', - 'expire' => 60, - 'throttle' => 60, - ], diff --git a/tests/stubs/config/auth/_providers.stub b/tests/stubs/config/auth/_providers.stub deleted file mode 100644 index e35c985..0000000 --- a/tests/stubs/config/auth/_providers.stub +++ /dev/null @@ -1,4 +0,0 @@ - 'customers' => [ - 'driver' => 'eloquent', - 'model' => App\Models\Customer::class, - ], diff --git a/tests/stubs/config/auth/models-auth.php b/tests/stubs/config/auth/models-auth.php deleted file mode 100644 index d83b5bd..0000000 --- a/tests/stubs/config/auth/models-auth.php +++ /dev/null @@ -1,199 +0,0 @@ - [ - 'guard' => env('AUTH_GUARD', 'web_customer'), - 'passwords' => env('AUTH_PASSWORD_BROKER', 'customers'), - ], - - /* - |-------------------------------------------------------------------------- - | Authentication Guards - |-------------------------------------------------------------------------- - | - | Next, you may define every authentication guard for your application. - | Of course, a great default configuration has been defined for you - | which utilizes session storage plus the Eloquent user provider. - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | Supported: "session" - | - */ - - 'guards' => [ - 'web_public_user' => [ - 'driver' => 'session', - 'provider' => 'public_users', - ], - - 'api_public_user' => [ - 'driver' => 'passport', - 'provider' => 'public_users', - ], - - 'web_customer' => [ - 'driver' => 'session', - 'provider' => 'customers', - ], - - 'api_customer' => [ - 'driver' => 'passport', - 'provider' => 'customers', - ], - - 'web_admin' => [ - 'driver' => 'session', - 'provider' => 'users', - ], - - 'api_admin' => [ - 'driver' => 'passport', - 'provider' => 'users', - ], - - 'api' => [ - 'driver' => 'passport', - 'provider' => 'users', - // 'hash' => false, - ], - ], - - /* - |-------------------------------------------------------------------------- - | User Providers - |-------------------------------------------------------------------------- - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | If you have multiple user tables or models you may configure multiple - | providers to represent the model / table. These providers may then - | be assigned to any extra authentication guards you have defined. - | - | Supported: "database", "eloquent" - | - */ - - 'providers' => [ - 'public_users' => [ - 'driver' => 'eloquent', - 'model' => App\Models\PublicUser::class, - ], - - 'customers' => [ - 'driver' => 'eloquent', - 'model' => App\Models\Customer::class, - ], - - 'users' => [ - 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', App\Models\User::class), - ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], - ], - - /* - |-------------------------------------------------------------------------- - | Resetting Passwords - |-------------------------------------------------------------------------- - | - | These configuration options specify the behavior of Laravel's password - | reset functionality, including the table utilized for token storage - | and the user provider that is invoked to actually retrieve users. - | - | The expiry time is the number of minutes that each reset token will be - | considered valid. This security feature keeps tokens short-lived so - | they have less time to be guessed. You may change this as needed. - | - | The throttle setting is the number of seconds a user must wait before - | generating more password reset tokens. This prevents the user from - | quickly generating a very large amount of password reset tokens. - | - */ - - 'passwords' => [ - 'public_users' => [ - 'provider' => 'public_users', - 'table' => 'public_user_password_reset_tokens', - 'expire' => 60, - 'throttle' => 60, - ], - - 'customers' => [ - 'provider' => 'customers', - 'table' => 'customer_password_reset_tokens', - 'expire' => 60, - 'throttle' => 60, - ], - - 'users' => [ - 'provider' => 'users', - 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), - 'expire' => 60, - 'throttle' => 60, - ], - ], - - /* - |-------------------------------------------------------------------------- - | Password Confirmation Timeout - |-------------------------------------------------------------------------- - | - | Here you may define the amount of seconds before a password confirmation - | window expires and users are asked to re-enter their password via the - | confirmation screen. By default, the timeout lasts for three hours. - | - */ - - 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), - - /* - |-------------------------------------------------------------------------- - | Max login attempts - |-------------------------------------------------------------------------- - | - | The maximum login attempts allowed before a user gets locked out. - | This is a custom configuration. - | - */ - - 'max_login_attempts' => env('MAX_LOGIN_ATTEMPTS', 5), - - /* - |-------------------------------------------------------------------------- - | Passport API Guards - |-------------------------------------------------------------------------- - | - | Guards used by Passport for OAuthClientAuth. Put the guards in priority - | order of least authorized first, then most authorized - | - */ - - 'passport_guards' => [ - 'api_public_user', - 'api_customer', - 'api_admin', - 'api', - ], - -]; diff --git a/tests/stubs/config/auth/skeleton-auth.php b/tests/stubs/config/auth/skeleton-auth.php deleted file mode 100644 index 0974add..0000000 --- a/tests/stubs/config/auth/skeleton-auth.php +++ /dev/null @@ -1,153 +0,0 @@ - [ - 'guard' => env('AUTH_GUARD', 'web_admin'), - 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), - ], - - /* - |-------------------------------------------------------------------------- - | Authentication Guards - |-------------------------------------------------------------------------- - | - | Next, you may define every authentication guard for your application. - | Of course, a great default configuration has been defined for you - | which utilizes session storage plus the Eloquent user provider. - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | Supported: "session" - | - */ - - 'guards' => [ - 'web_admin' => [ - 'driver' => 'session', - 'provider' => 'users', - ], - - 'api_admin' => [ - 'driver' => 'passport', - 'provider' => 'users', - ], - - 'api' => [ - 'driver' => 'passport', - 'provider' => 'users', - // 'hash' => false, - ], - ], - - /* - |-------------------------------------------------------------------------- - | User Providers - |-------------------------------------------------------------------------- - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | If you have multiple user tables or models you may configure multiple - | providers to represent the model / table. These providers may then - | be assigned to any extra authentication guards you have defined. - | - | Supported: "database", "eloquent" - | - */ - - 'providers' => [ - 'users' => [ - 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', App\Models\User::class), - ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], - ], - - /* - |-------------------------------------------------------------------------- - | Resetting Passwords - |-------------------------------------------------------------------------- - | - | These configuration options specify the behavior of Laravel's password - | reset functionality, including the table utilized for token storage - | and the user provider that is invoked to actually retrieve users. - | - | The expiry time is the number of minutes that each reset token will be - | considered valid. This security feature keeps tokens short-lived so - | they have less time to be guessed. You may change this as needed. - | - | The throttle setting is the number of seconds a user must wait before - | generating more password reset tokens. This prevents the user from - | quickly generating a very large amount of password reset tokens. - | - */ - - 'passwords' => [ - 'users' => [ - 'provider' => 'users', - 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), - 'expire' => 60, - 'throttle' => 60, - ], - ], - - /* - |-------------------------------------------------------------------------- - | Password Confirmation Timeout - |-------------------------------------------------------------------------- - | - | Here you may define the amount of seconds before a password confirmation - | window expires and users are asked to re-enter their password via the - | confirmation screen. By default, the timeout lasts for three hours. - | - */ - - 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), - - /* - |-------------------------------------------------------------------------- - | Max login attempts - |-------------------------------------------------------------------------- - | - | The maximum login attempts allowed before a user gets locked out. - | This is a custom configuration. - | - */ - - 'max_login_attempts' => env('MAX_LOGIN_ATTEMPTS', 5), - - /* - |-------------------------------------------------------------------------- - | Passport API Guards - |-------------------------------------------------------------------------- - | - | Guards used by Passport for OAuthClientAuth. Put the guards in priority - | order of least authorized first, then most authorized - | - */ - - 'passport_guards' => [ - 'api_admin', - 'api', - ], - -]; diff --git a/tests/stubs/factories/CategoryFactory.php b/tests/stubs/factories/CategoryFactory.php deleted file mode 100644 index eb6da79..0000000 --- a/tests/stubs/factories/CategoryFactory.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ -class CategoryFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'name' => fake()->unique()->text(fake()->numberBetween(5, 255)), - 'slug' => fake()->unique()->slug(), - ]; - } - -} diff --git a/tests/stubs/factories/CountryFactory.php b/tests/stubs/factories/CountryFactory.php deleted file mode 100644 index 73f10b2..0000000 --- a/tests/stubs/factories/CountryFactory.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ -class CountryFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'name' => fake()->text(fake()->numberBetween(5, 255)), - 'code' => fake()->optional()->unique()?->regexify('[a-z]{4}'), - ]; - } - -} diff --git a/tests/stubs/factories/CustomerFactory.php b/tests/stubs/factories/CustomerFactory.php deleted file mode 100644 index fcf390d..0000000 --- a/tests/stubs/factories/CustomerFactory.php +++ /dev/null @@ -1,41 +0,0 @@ - - */ -class CustomerFactory extends \Javaabu\Auth\Factories\UserFactory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - $definitions = parent::definition(); - - $definitions['password'] = 'Customer@123456'; - - $definitions += [ - 'designation' => fake()->unique()->text(fake()->numberBetween(5, 255)), - 'address' => fake()->address(), - 'on_sale' => fake()->boolean(), - 'expire_at' => fake()->dateTime()?->format('Y-m-d H:i'), - ]; - - return $definitions; - } - - public function withCategory(): CustomerFactory - { - return $this->state(function (array $attributes) { - return [ - 'category_id' => fake()->passThrough(random_id_or_generate(\App\Models\Category::class, 'id', generate: true)), - ]; - }); - } -} diff --git a/tests/stubs/factories/OrderFactory.php b/tests/stubs/factories/OrderFactory.php deleted file mode 100644 index eaf7141..0000000 --- a/tests/stubs/factories/OrderFactory.php +++ /dev/null @@ -1,48 +0,0 @@ - - */ -class OrderFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'order_no' => fake()->regexify('[a-z]{4}'), - 'status' => fake()->randomElement(array_column(\Javaabu\Generators\Tests\TestSupport\Enums\OrderStatuses::cases(), 'value')), - ]; - } - - public function withCategory(): OrderFactory - { - return $this->state(function (array $attributes) { - return [ - 'category_id' => fake()->passThrough(random_id_or_generate(\App\Models\Category::class, 'id', generate: true)), - ]; - }); - } - - public function withProductSlug(): OrderFactory - { - return $this->state(function (array $attributes) { - return [ - 'product_slug' => fake()->passThrough(random_id_or_generate(\App\Models\Product::class, 'slug', generate: true)), - ]; - }); - } - - public function withRequiredRelations(): OrderFactory - { - return $this->withCategory() - ->withProduct(); - } -} diff --git a/tests/stubs/factories/PaymentFactory.php b/tests/stubs/factories/PaymentFactory.php deleted file mode 100644 index 7a9a86d..0000000 --- a/tests/stubs/factories/PaymentFactory.php +++ /dev/null @@ -1,38 +0,0 @@ - - */ -class PaymentFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'amount' => fake()->randomFloat(2, -99999999999, 99999999999), - 'status' => fake()->optional()->randomElement(array_column(\App\Enums\PaymentStatuses::cases(), 'value')), - ]; - } - - public function withOrder(): PaymentFactory - { - return $this->state(function (array $attributes) { - return [ - 'order_id' => fake()->unique()->passThrough(random_id_or_generate(\App\Models\Order::class, 'id', generate: true, unique: true)), - ]; - }); - } - - public function withRequiredRelations(): PaymentFactory - { - return $this->withOrder(); - } -} diff --git a/tests/stubs/factories/ProductFactory.php b/tests/stubs/factories/ProductFactory.php deleted file mode 100644 index 99f6e49..0000000 --- a/tests/stubs/factories/ProductFactory.php +++ /dev/null @@ -1,45 +0,0 @@ - - */ -class ProductFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'name' => fake()->text(fake()->numberBetween(5, 255)), - 'address' => fake()->address(), - 'slug' => fake()->unique()->slug(), - 'description' => fake()->optional()->sentences(3, true), - 'price' => fake()->randomFloat(2, 0, 999999999999), - 'stock' => fake()->numberBetween(0, 4294967295), - 'on_sale' => fake()->boolean(), - 'features' => fake()->words(), - 'published_at' => fake()->dateTime()?->format('Y-m-d H:i'), - 'expire_at' => fake()->dateTime()?->format('Y-m-d H:i'), - 'released_on' => fake()->date(), - 'sale_time' => fake()->time(), - 'status' => fake()->randomElement(['draft', 'published']), - 'manufactured_year' => fake()->year(2100), - ]; - } - - public function withCategory(): ProductFactory - { - return $this->state(function (array $attributes) { - return [ - 'category_id' => fake()->passThrough(random_id_or_generate(\App\Models\Category::class, 'id', generate: true)), - ]; - }); - } -} diff --git a/tests/stubs/migrations/create_customer_password_reset_tokens_table.php b/tests/stubs/migrations/create_customer_password_reset_tokens_table.php deleted file mode 100644 index 9622ce4..0000000 --- a/tests/stubs/migrations/create_customer_password_reset_tokens_table.php +++ /dev/null @@ -1,32 +0,0 @@ -string('email')->index(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('customer_password_reset_tokens'); - } -}; diff --git a/tests/stubs/routes/_admin.stub b/tests/stubs/routes/_admin.stub deleted file mode 100644 index 72651e9..0000000 --- a/tests/stubs/routes/_admin.stub +++ /dev/null @@ -1,5 +0,0 @@ - /** - * Categories - */ - Route::match(['PUT', 'PATCH'], 'categories', [CategoriesController::class, 'bulk'])->name('categories.bulk'); - Route::resource('categories', CategoriesController::class); diff --git a/tests/stubs/routes/_adminSoftDeletes.stub b/tests/stubs/routes/_adminSoftDeletes.stub deleted file mode 100644 index 5a522c4..0000000 --- a/tests/stubs/routes/_adminSoftDeletes.stub +++ /dev/null @@ -1,8 +0,0 @@ - /** - * Products - */ - Route::match(['PUT', 'PATCH'], 'products', [ProductsController::class, 'bulk'])->name('products.bulk'); - Route::get('products/trash', [ProductsController::class, 'trash'])->name('products.trash'); - Route::post('products/{product}/restore', [ProductsController::class, 'restore'])->name('products.restore')->withTrashed(); - Route::delete('products/{product}/force-delete', [ProductsController::class, 'forceDelete'])->name('products.force-delete')->withTrashed(); - Route::resource('products', ProductsController::class); diff --git a/tests/stubs/routes/_api.stub b/tests/stubs/routes/_api.stub deleted file mode 100644 index c3182be..0000000 --- a/tests/stubs/routes/_api.stub +++ /dev/null @@ -1,5 +0,0 @@ - /** - * Categories - */ - Route::get('categories', [CategoriesController::class, 'index'])->name('categories.index'); - Route::get('categories/{id}', [CategoriesController::class, 'show'])->name('categories.show'); diff --git a/tests/stubs/routes/modelsAdmin.php b/tests/stubs/routes/modelsAdmin.php deleted file mode 100644 index 9371a03..0000000 --- a/tests/stubs/routes/modelsAdmin.php +++ /dev/null @@ -1,131 +0,0 @@ - 'Auth', -], function () { - Route::get('login', [LoginController::class, 'showLoginForm'])->name('login'); - Route::post('login', [LoginController::class, 'login'])->name('login-post'); - Route::post('logout', [LoginController::class, 'logout'])->name('logout'); - - // Registration Routes... - // Route::get('register', [RegisterController::class, 'showRegistrationForm'])->name( 'register'); - // Route::post('register', [RegisterController::class, 'register'])->name( 'register-post'); - - // Password - Route::group([ - 'prefix' => 'password', - 'as' => 'password.' - ], function () { - // Forgot Password - Route::get('reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('request'); - Route::post('email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('email'); - Route::get('reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('reset'); - Route::post('reset', [ResetPasswordController::class, 'reset'])->name('update'); - - // Confirm Password - Route::get('confirm', [ConfirmPasswordController::class, 'showConfirmForm'])->name('confirm'); - Route::post('confirm', [ConfirmPasswordController::class, 'confirm'])->name('confirm-post'); - - // Password Update - Route::get('update', [UpdatePasswordController::class, 'showPasswordUpdateForm'])->name('new-password'); - Route::post('update', [UpdatePasswordController::class, 'updatePassword'])->name('new-password-post'); - }); - - // Email Verification - Route::group([ - 'prefix' => 'verify', - 'as' => 'verification.' - ], function () { - Route::get('/', [VerificationController::class, 'show'])->name('notice'); - Route::post('email/resend', [VerificationController::class, 'resend'])->name('resend'); - Route::get('email/{id}/{hash}', [VerificationController::class, 'verify'])->name('verify'); - }); -}); - - -/** - * Protected routes - */ -Route::group([ - 'middleware' => ['auth:web_admin', 'active:web_admin', 'password-update-not-required:web_admin'], -], function () { - Route::get('/', [HomeController::class, 'index'])->name('home'); - - if (app()->runningUnitTests()) { - Route::get('test', function () { - return response()->json('It works'); - })->middleware('password.confirm:web_admin,admin.password.confirm'); - } - - // Generator Routes - DONOT REMOVE - - /** - * Products - */ - Route::match(['PUT', 'PATCH'], 'products', [ProductsController::class, 'bulk'])->name('products.bulk'); - Route::get('products/trash', [ProductsController::class, 'trash'])->name('products.trash'); - Route::post('products/{product}/restore', [ProductsController::class, 'restore'])->name('products.restore')->withTrashed(); - Route::delete('products/{product}/force-delete', [ProductsController::class, 'forceDelete'])->name('products.force-delete')->withTrashed(); - Route::resource('products', ProductsController::class); - - /** - * Categories - */ - Route::match(['PUT', 'PATCH'], 'categories', [CategoriesController::class, 'bulk'])->name('categories.bulk'); - Route::resource('categories', CategoriesController::class); - - /** - * Users - */ - - // profile - Route::get('profile', [UsersController::class, 'profile'])->name('users.profile'); - Route::match(['PUT', 'PATCH'], 'profile', [UsersController::class, 'updateProfile'])->name('users.profile.update'); - Route::delete('profile', [UsersController::class, 'deleteProfile'])->name('users.profile.destroy'); - - Route::match(['PUT', 'PATCH'], 'users', [UsersController::class, 'bulk'])->name('users.bulk'); - Route::get('users/trash', [UsersController::class, 'trash'])->name('users.trash'); - Route::post('users/{user}/restore', [UsersController::class, 'restore'])->name('users.restore')->withTrashed(); - Route::delete('users/{user}/force-delete', [UsersController::class, 'forceDelete'])->name('users.force-delete')->withTrashed(); - Route::resource('users', UsersController::class); - - /** - * Roles - */ - Route::match(['PUT', 'PATCH'], 'roles', [RolesController::class, 'bulk'])->name('roles.bulk'); - Route::resource('roles', RolesController::class); - - /** - * Logs - */ - Route::resource('logs', LogsController::class)->only(['index', 'show']); - - /** - * OAuth Clients - */ - Route::match(['PUT', 'PATCH'], 'oauth-clients', [OAuthClientsController::class, 'bulk'])->name('oauth-clients.bulk'); - Route::resource('oauth-clients', OAuthClientsController::class); - - /** - * Routes for settings - */ - Route::get('settings', [SettingsController::class, 'index'])->name('settings.index'); - Route::patch('settings', [SettingsController::class, 'update'])->name('settings.update'); - Route::post('settings', [SettingsController::class, 'reset'])->name('settings.reset'); -}); diff --git a/tests/stubs/routes/modelsApi.php b/tests/stubs/routes/modelsApi.php deleted file mode 100644 index c99a204..0000000 --- a/tests/stubs/routes/modelsApi.php +++ /dev/null @@ -1,105 +0,0 @@ - 'passport.', - 'prefix' => config('passport.path', 'oauth'), - 'namespace' => '\Laravel\Passport\Http\Controllers', -], function () { - Route::post('/token', [ - 'uses' => 'AccessTokenController@issueToken', - 'as' => 'token', - 'middleware' => 'throttle', - ]); -}); - - -/** - * Public routes - */ -Route::group([ - 'middleware' => ['oauth.client:read'], -], function () { - - /** - * Public upload routes - */ - //Route::post('users', ['uses' => 'UsersController@store', 'as' => 'users.store']); - - /** - * Public JSON routes - */ - Route::group([ - 'middleware' => ['json'], - ], function () { - - if (app()->runningUnitTests()) { - Route::get('test', function () { - return response()->json('It works'); - }); - } - - // Api Public Routes - DONOT REMOVE - - /** - * Products - */ - Route::get('products', [ProductsController::class, 'index'])->name('products.index'); - Route::get('products/{id}', [ProductsController::class, 'show'])->name('products.show'); - - /** - * Categories - */ - Route::get('categories', [CategoriesController::class, 'index'])->name('categories.index'); - Route::get('categories/{id}', [CategoriesController::class, 'show'])->name('categories.show'); - }); -}); - -/** - * Protected routes - */ -Route::group([ - 'middleware' => ['auth:api', 'active:api'], -], function () { - - /** - * User avatar - */ - //Route::match(['PUT', 'PATCH'], 'users/profile', ['uses' => 'UsersController@update', 'as' => 'users.update']); - - /** - * Protected JSON routes - */ - Route::group([ - 'middleware' => ['json'], - ], function () { - - /** - * Auth - */ - Route::post('oauth/revoke', [UsersController::class, 'revoke']); - - /** - * Users - */ - Route::get('users/profile', [UsersController::class, 'profile'])->name('users.profile'); - Route::get('users', [UsersController::class, 'index'])->name('users.index'); - Route::get('users/{id}', [UsersController::class, 'show'])->name('users.show'); - - }); -}); diff --git a/tests/stubs/routes/skeletonAdmin.php b/tests/stubs/routes/skeletonAdmin.php deleted file mode 100644 index 3e231aa..0000000 --- a/tests/stubs/routes/skeletonAdmin.php +++ /dev/null @@ -1,114 +0,0 @@ - 'Auth', -], function () { - Route::get('login', [LoginController::class, 'showLoginForm'])->name('login'); - Route::post('login', [LoginController::class, 'login'])->name('login-post'); - Route::post('logout', [LoginController::class, 'logout'])->name('logout'); - - // Registration Routes... - // Route::get('register', [RegisterController::class, 'showRegistrationForm'])->name( 'register'); - // Route::post('register', [RegisterController::class, 'register'])->name( 'register-post'); - - // Password - Route::group([ - 'prefix' => 'password', - 'as' => 'password.' - ], function () { - // Forgot Password - Route::get('reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('request'); - Route::post('email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('email'); - Route::get('reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('reset'); - Route::post('reset', [ResetPasswordController::class, 'reset'])->name('update'); - - // Confirm Password - Route::get('confirm', [ConfirmPasswordController::class, 'showConfirmForm'])->name('confirm'); - Route::post('confirm', [ConfirmPasswordController::class, 'confirm'])->name('confirm-post'); - - // Password Update - Route::get('update', [UpdatePasswordController::class, 'showPasswordUpdateForm'])->name('new-password'); - Route::post('update', [UpdatePasswordController::class, 'updatePassword'])->name('new-password-post'); - }); - - // Email Verification - Route::group([ - 'prefix' => 'verify', - 'as' => 'verification.' - ], function () { - Route::get('/', [VerificationController::class, 'show'])->name('notice'); - Route::post('email/resend', [VerificationController::class, 'resend'])->name('resend'); - Route::get('email/{id}/{hash}', [VerificationController::class, 'verify'])->name('verify'); - }); -}); - - -/** - * Protected routes - */ -Route::group([ - 'middleware' => ['auth:web_admin', 'active:web_admin', 'password-update-not-required:web_admin'], -], function () { - Route::get('/', [HomeController::class, 'index'])->name('home'); - - if (app()->runningUnitTests()) { - Route::get('test', function () { - return response()->json('It works'); - })->middleware('password.confirm:web_admin,admin.password.confirm'); - } - - // Generator Routes - DONOT REMOVE - - /** - * Users - */ - - // profile - Route::get('profile', [UsersController::class, 'profile'])->name('users.profile'); - Route::match(['PUT', 'PATCH'], 'profile', [UsersController::class, 'updateProfile'])->name('users.profile.update'); - Route::delete('profile', [UsersController::class, 'deleteProfile'])->name('users.profile.destroy'); - - Route::match(['PUT', 'PATCH'], 'users', [UsersController::class, 'bulk'])->name('users.bulk'); - Route::get('users/trash', [UsersController::class, 'trash'])->name('users.trash'); - Route::post('users/{user}/restore', [UsersController::class, 'restore'])->name('users.restore')->withTrashed(); - Route::delete('users/{user}/force-delete', [UsersController::class, 'forceDelete'])->name('users.force-delete')->withTrashed(); - Route::resource('users', UsersController::class); - - /** - * Roles - */ - Route::match(['PUT', 'PATCH'], 'roles', [RolesController::class, 'bulk'])->name('roles.bulk'); - Route::resource('roles', RolesController::class); - - /** - * Logs - */ - Route::resource('logs', LogsController::class)->only(['index', 'show']); - - /** - * OAuth Clients - */ - Route::match(['PUT', 'PATCH'], 'oauth-clients', [OAuthClientsController::class, 'bulk'])->name('oauth-clients.bulk'); - Route::resource('oauth-clients', OAuthClientsController::class); - - /** - * Routes for settings - */ - Route::get('settings', [SettingsController::class, 'index'])->name('settings.index'); - Route::patch('settings', [SettingsController::class, 'update'])->name('settings.update'); - Route::post('settings', [SettingsController::class, 'reset'])->name('settings.reset'); -}); diff --git a/tests/stubs/routes/skeletonApi.php b/tests/stubs/routes/skeletonApi.php deleted file mode 100644 index a45c5d1..0000000 --- a/tests/stubs/routes/skeletonApi.php +++ /dev/null @@ -1,91 +0,0 @@ - 'passport.', - 'prefix' => config('passport.path', 'oauth'), - 'namespace' => '\Laravel\Passport\Http\Controllers', -], function () { - Route::post('/token', [ - 'uses' => 'AccessTokenController@issueToken', - 'as' => 'token', - 'middleware' => 'throttle', - ]); -}); - - -/** - * Public routes - */ -Route::group([ - 'middleware' => ['oauth.client:read'], -], function () { - - /** - * Public upload routes - */ - //Route::post('users', ['uses' => 'UsersController@store', 'as' => 'users.store']); - - /** - * Public JSON routes - */ - Route::group([ - 'middleware' => ['json'], - ], function () { - - if (app()->runningUnitTests()) { - Route::get('test', function () { - return response()->json('It works'); - }); - } - - // Api Public Routes - DONOT REMOVE - }); -}); - -/** - * Protected routes - */ -Route::group([ - 'middleware' => ['auth:api', 'active:api'], -], function () { - - /** - * User avatar - */ - //Route::match(['PUT', 'PATCH'], 'users/profile', ['uses' => 'UsersController@update', 'as' => 'users.update']); - - /** - * Protected JSON routes - */ - Route::group([ - 'middleware' => ['json'], - ], function () { - - /** - * Auth - */ - Route::post('oauth/revoke', [UsersController::class, 'revoke']); - - /** - * Users - */ - Route::get('users/profile', [UsersController::class, 'profile'])->name('users.profile'); - Route::get('users', [UsersController::class, 'index'])->name('users.index'); - Route::get('users/{id}', [UsersController::class, 'show'])->name('users.show'); - - }); -}); diff --git a/tests/stubs/seeders/AuthPermissionsSeederExisting.php b/tests/stubs/seeders/AuthPermissionsSeederExisting.php deleted file mode 100644 index ff0e49b..0000000 --- a/tests/stubs/seeders/AuthPermissionsSeederExisting.php +++ /dev/null @@ -1,93 +0,0 @@ - [ - 'view_public_users' => 'View public users', - 'edit_public_users' => 'Edit public users', - 'delete_public_users' => 'Delete public users', - 'force_delete_public_users' => 'Permanently delete public users', - 'approve_public_users' => 'Approve public users', - ], - - /** - * Customers permissions - */ - 'customers' => [ - 'view_customers' => 'View customers', - 'edit_customers' => 'Edit customers', - 'delete_customers' => 'Delete customers', - 'force_delete_customers' => 'Permanently delete customers', - 'approve_customers' => 'Approve customers', - ], - - /** - * Users permissions - */ - 'users' => [ - 'view_users' => 'View users', - 'edit_users' => 'Edit users', - 'approve_users' => 'Approve users', - 'delete_users' => 'Delete users', - 'force_delete_users' => 'Permanently delete users', - ], - - /** - * OAuth Clients permissions - */ - 'oauth_clients' => [ - 'view_oauth_clients' => 'View OAuth2 clients', - 'edit_oauth_clients' => 'Edit own OAuth2 clients', - 'edit_others_oauth_clients' => 'Edit all OAuth2 clients', - 'delete_oauth_clients' => 'Delete own OAuth2 clients', - 'delete_others_oauth_clients' => 'Delete all OAuth2 clients', - ], - - /** - * Roles permissions - */ - 'roles' => [ - 'view_roles' => 'View roles', - 'edit_roles' => 'Edit roles', - 'delete_roles' => 'Delete roles', - ], - - /** - * Activity log permissions - */ - 'logs' => [ - 'view_logs' => 'View logs of authorized models', - 'view_all_logs' => 'View all logs', - ], - - /** - * Settings permissions - */ - 'settings' => [ - 'edit_settings' => 'Edit settings', - ], - ]; - - /** - * Run the database seeds. - */ - public function run(): void - { - foreach ($this->data as $model => $permissions) { - foreach ($permissions as $name => $desc) { - $permission = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web_admin']); - $permission->update(['description' => $desc, 'model' => $model]); - $permission->save(); - } - } - } -} diff --git a/tests/stubs/seeders/AuthPermissionsSeederNew.php b/tests/stubs/seeders/AuthPermissionsSeederNew.php deleted file mode 100644 index 815f6a0..0000000 --- a/tests/stubs/seeders/AuthPermissionsSeederNew.php +++ /dev/null @@ -1,48 +0,0 @@ - [ - 'view_public_users' => 'View public users', - 'edit_public_users' => 'Edit public users', - 'delete_public_users' => 'Delete public users', - 'force_delete_public_users' => 'Permanently delete public users', - 'approve_public_users' => 'Approve public users', - ], - - /** - * Customers permissions - */ - 'customers' => [ - 'view_customers' => 'View customers', - 'edit_customers' => 'Edit customers', - 'delete_customers' => 'Delete customers', - 'force_delete_customers' => 'Permanently delete customers', - 'approve_customers' => 'Approve customers', - ], - - ]; - - /** - * Run the database seeds. - */ - public function run(): void - { - foreach ($this->data as $model => $permissions) { - foreach ($permissions as $name => $desc) { - $permission = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web_admin']); - $permission->update(['description' => $desc, 'model' => $model]); - $permission->save(); - } - } - } -} diff --git a/tests/stubs/seeders/DefaultUsersSeeder.php b/tests/stubs/seeders/DefaultUsersSeeder.php deleted file mode 100644 index 5da2306..0000000 --- a/tests/stubs/seeders/DefaultUsersSeeder.php +++ /dev/null @@ -1,44 +0,0 @@ - 'Admin User', 'role' => 'super_admin', 'email' => 'demo-admin@javaabu.com', 'password' => 'RandomPassword12345'], - ['name' => 'Guest User', 'role' => 'guest', 'email' => 'demo-guest@javaabu.com', 'password' => 'RandomPassword12345'], - ]; - - /** - * Run the database seeds. - * - * @return void - */ - public function run() - { - foreach ($this->data as $user_data) { - $user = User::whereEmail($user_data['email'])->first(); - if ($user) { - $user->update(Arr::except($user_data, 'role')); - } else { - $user = new User(Arr::except($user_data, 'role')); - } - - $user->status = \Javaabu\Auth\Enums\UserStatuses::APPROVED; - $user->email = $user_data['email']; - $user->password = $user_data['password']; - $user->resetLoginAttempts(); - - // save and verify - $user->markEmailAsVerified(); - - if (! empty($user_data['role'])) { - $user->syncRoles($user_data['role']); - } - } - } -} diff --git a/tests/stubs/seeders/PermissionsSeeder.php b/tests/stubs/seeders/PermissionsSeeder.php deleted file mode 100644 index d33548f..0000000 --- a/tests/stubs/seeders/PermissionsSeeder.php +++ /dev/null @@ -1,71 +0,0 @@ - [ - 'view_users' => 'View users', - 'edit_users' => 'Edit users', - 'approve_users' => 'Approve users', - 'delete_users' => 'Delete users', - 'force_delete_users' => 'Permanently delete users', - ], - - /** - * OAuth Clients permissions - */ - 'oauth_clients' => [ - 'view_oauth_clients' => 'View OAuth2 clients', - 'edit_oauth_clients' => 'Edit own OAuth2 clients', - 'edit_others_oauth_clients' => 'Edit all OAuth2 clients', - 'delete_oauth_clients' => 'Delete own OAuth2 clients', - 'delete_others_oauth_clients' => 'Delete all OAuth2 clients', - ], - - /** - * Roles permissions - */ - 'roles' => [ - 'view_roles' => 'View roles', - 'edit_roles' => 'Edit roles', - 'delete_roles' => 'Delete roles', - ], - - /** - * Activity log permissions - */ - 'logs' => [ - 'view_logs' => 'View logs of authorized models', - 'view_all_logs' => 'View all logs', - ], - - /** - * Settings permissions - */ - 'settings' => [ - 'edit_settings' => 'Edit settings', - ], - ]; - - /** - * Run the database seeds. - */ - public function run(): void - { - foreach ($this->data as $model => $permissions) { - foreach ($permissions as $name => $desc) { - $permission = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web_admin']); - $permission->update(['description' => $desc, 'model' => $model]); - $permission->save(); - } - } - } -} diff --git a/tests/stubs/seeders/PermissionsSeederExisting.php b/tests/stubs/seeders/PermissionsSeederExisting.php deleted file mode 100644 index c949412..0000000 --- a/tests/stubs/seeders/PermissionsSeederExisting.php +++ /dev/null @@ -1,90 +0,0 @@ - [ - 'view_categories' => 'View categories', - 'edit_categories' => 'Edit categories', - 'delete_categories' => 'Delete categories', - ], - - /** - * Products permissions - */ - 'products' => [ - 'view_products' => 'View products', - 'edit_products' => 'Edit products', - 'delete_products' => 'Delete products', - 'force_delete_products' => 'Permanently delete products', - ], - - /** - * Users permissions - */ - 'users' => [ - 'view_users' => 'View users', - 'edit_users' => 'Edit users', - 'approve_users' => 'Approve users', - 'delete_users' => 'Delete users', - 'force_delete_users' => 'Permanently delete users', - ], - - /** - * OAuth Clients permissions - */ - 'oauth_clients' => [ - 'view_oauth_clients' => 'View OAuth2 clients', - 'edit_oauth_clients' => 'Edit own OAuth2 clients', - 'edit_others_oauth_clients' => 'Edit all OAuth2 clients', - 'delete_oauth_clients' => 'Delete own OAuth2 clients', - 'delete_others_oauth_clients' => 'Delete all OAuth2 clients', - ], - - /** - * Roles permissions - */ - 'roles' => [ - 'view_roles' => 'View roles', - 'edit_roles' => 'Edit roles', - 'delete_roles' => 'Delete roles', - ], - - /** - * Activity log permissions - */ - 'logs' => [ - 'view_logs' => 'View logs of authorized models', - 'view_all_logs' => 'View all logs', - ], - - /** - * Settings permissions - */ - 'settings' => [ - 'edit_settings' => 'Edit settings', - ], - ]; - - /** - * Run the database seeds. - */ - public function run(): void - { - foreach ($this->data as $model => $permissions) { - foreach ($permissions as $name => $desc) { - $permission = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web_admin']); - $permission->update(['description' => $desc, 'model' => $model]); - $permission->save(); - } - } - } -} diff --git a/tests/stubs/seeders/PermissionsSeederNew.php b/tests/stubs/seeders/PermissionsSeederNew.php deleted file mode 100644 index d4ce2f2..0000000 --- a/tests/stubs/seeders/PermissionsSeederNew.php +++ /dev/null @@ -1,45 +0,0 @@ - [ - 'view_categories' => 'View categories', - 'edit_categories' => 'Edit categories', - 'delete_categories' => 'Delete categories', - ], - - /** - * Products permissions - */ - 'products' => [ - 'view_products' => 'View products', - 'edit_products' => 'Edit products', - 'delete_products' => 'Delete products', - 'force_delete_products' => 'Permanently delete products', - ], - - ]; - - /** - * Run the database seeds. - */ - public function run(): void - { - foreach ($this->data as $model => $permissions) { - foreach ($permissions as $name => $desc) { - $permission = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web_admin']); - $permission->update(['description' => $desc, 'model' => $model]); - $permission->save(); - } - } - } -} diff --git a/tests/stubs/seeders/_authPermissions.stub b/tests/stubs/seeders/_authPermissions.stub deleted file mode 100644 index e11be04..0000000 --- a/tests/stubs/seeders/_authPermissions.stub +++ /dev/null @@ -1,10 +0,0 @@ - /** - * Customers permissions - */ - 'customers' => [ - 'view_customers' => 'View customers', - 'edit_customers' => 'Edit customers', - 'delete_customers' => 'Delete customers', - 'force_delete_customers' => 'Permanently delete customers', - 'approve_customers' => 'Approve customers', - ], diff --git a/tests/stubs/seeders/_permissions.stub b/tests/stubs/seeders/_permissions.stub deleted file mode 100644 index 313c8c9..0000000 --- a/tests/stubs/seeders/_permissions.stub +++ /dev/null @@ -1,8 +0,0 @@ - /** - * Categories permissions - */ - 'categories' => [ - 'view_categories' => 'View categories', - 'edit_categories' => 'Edit categories', - 'delete_categories' => 'Delete categories', - ], diff --git a/tests/stubs/seeders/_permissionsSoftDeletes.stub b/tests/stubs/seeders/_permissionsSoftDeletes.stub deleted file mode 100644 index c6560f1..0000000 --- a/tests/stubs/seeders/_permissionsSoftDeletes.stub +++ /dev/null @@ -1,9 +0,0 @@ - /** - * Products permissions - */ - 'products' => [ - 'view_products' => 'View products', - 'edit_products' => 'Edit products', - 'delete_products' => 'Delete products', - 'force_delete_products' => 'Permanently delete products', - ], diff --git a/tests/stubs/tests/Api/CategoriesControllerTest.stub b/tests/stubs/tests/Api/CategoriesControllerTest.stub deleted file mode 100644 index e5adddc..0000000 --- a/tests/stubs/tests/Api/CategoriesControllerTest.stub +++ /dev/null @@ -1,78 +0,0 @@ -withoutExceptionHandling(); - - $access_cookie = $this->getOAuthCookie(null); - - $categories = $this->getFactory(Category::class)->count(10)->create(); - - $this->jsonApi('get', '/api/v1/categories', access_cookie: $access_cookie) - ->assertSuccessful() - ->assertJsonFragment([ - 'name' => $categories->first()->name, - ]); - - } - - #[Test] - public function it_can_show_api_categories_for_public_users(): void - { - $this->withoutExceptionHandling(); - - $access_cookie = $this->getOAuthCookie(null); - - $category = $this->getFactory(Category::class)->create(); - - $this->jsonApi('get', "/api/v1/categories/{$category->id}", access_cookie: $access_cookie) - ->assertSuccessful() - ->assertJsonFragment([ - 'name' => $category->name, - ]); - } - - #[Test] - public function it_can_list_api_categories_for_admin_users(): void - { - $this->withoutExceptionHandling(); - - $this->actingAsApiAdmin('view_categories'); - - $categories = $this->getFactory(Category::class)->count(10)->create(); - - $this->getJson('/api/v1/categories') - ->assertSuccessful() - ->assertJsonFragment([ - 'name' => $categories->first()->name, - ]); - - } - - #[Test] - public function it_can_show_api_categories_for_admin_users(): void - { - $this->withoutExceptionHandling(); - - $this->actingAsApiAdmin('view_categories'); - - $category = $this->getFactory(Category::class)->create(); - - $this->getJson("/api/v1/categories/{$category->id}") - ->assertSuccessful() - ->assertJsonFragment([ - 'name' => $category->name, - ]); - } -} diff --git a/tests/stubs/tests/CategoriesControllerTest.stub b/tests/stubs/tests/CategoriesControllerTest.stub deleted file mode 100644 index 3bfa0bb..0000000 --- a/tests/stubs/tests/CategoriesControllerTest.stub +++ /dev/null @@ -1,369 +0,0 @@ -withoutExceptionHandling(); - - $this->actingAsAdmin('view_categories'); - - $category = $this->getFactory(Category::class)->create(); - - $this->adminGet('/categories') - ->assertStatus(200) - ->assertSee($category->name); - } - - #[Test] - public function it_can_filter_categories() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_categories'); - - $category = $this->getFactory(Category::class)->create(); - - $this->adminGet(add_query_arg([ - 'search' => $category->name, - 'date_field' => 'created_at', - 'date_from' => now()->subDay()->toDateTimeString(), - 'date_to' => now()->addDay()->toDateTimeString(), - ], '/categories')) - ->assertStatus(200) - ->assertSee($category->name); - } - - #[Test] - public function it_can_export_categories() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_categories'); - - $category = $this->getFactory(Category::class)->create(); - - Excel::fake(); - - $this->adminGet(add_query_arg(['download' => 1], '/categories')) - ->assertSuccessful(); - - Excel::assertDownloaded('categories.xlsx', function (CategoriesExport $export) { - return $export->modelClass() == Category::class; - }); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_list_categories() - { - $this->actingAsAdmin(); - - $category = $this->getFactory(Category::class)->create(); - - $this->adminGet('/categories') - ->assertStatus(403) - ->assertDontSee($category->name); - } - - #[Test] - public function it_can_show_the_create_new_category_page() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_categories'); - - $this->adminGet('/categories/create') - ->assertStatus(200) - ->assertSee('New Category'); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_see_the_create_new_category_page() - { - $this->actingAsAdmin(); - - $this->adminGet('/categories/create') - ->assertStatus(403) - ->assertDontSee('New Category'); - } - - #[Test] - public function it_can_create_a_new_category() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_categories'); - - $category = $this->getFactory(Category::class)->make(); - - $this->adminPost('/categories', [ - 'name' => $category->name, - 'slug' => $category->slug, - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $this->assertDatabaseHas('categories', [ - 'name' => $category->name, - 'slug' => $category->slug, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_add_a_category() - { - $this->actingAsAdmin(); - - $category = $this->getFactory(Category::class)->make(); - - $this->adminPost('/categories', [ - 'name' => $category->name, - 'slug' => $category->slug, - ]) - ->assertSessionMissing('errors') - ->assertStatus(403); - - $this->assertDatabaseMissing('categories', [ - 'name' => $category->name, - 'slug' => $category->slug, - ]); - } - - #[Test] - public function it_can_show_the_edit_category_page() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_categories'); - - $category = $this->getFactory(Category::class)->create(); - - $this->adminGet("/categories/{$category->id}/edit") - ->assertStatus(200) - ->assertSee($category->name); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_see_the_edit_category_page() - { - $this->actingAsAdmin(); - - $category = $this->getFactory(Category::class)->create(); - - $this->adminGet("/categories/{$category->id}/edit") - ->assertStatus(403) - ->assertDontSee($category->name); - } - - #[Test] - public function it_can_update_a_category() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_categories'); - - $category = Category::factory()->create([ - 'name' => 'foo', - 'slug' => 'foo', - ]); - - $this->adminPatch("/categories/{$category->id}", [ - 'name' => 'bar', - 'slug' => 'bar', - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $this->assertDatabaseHas('categories', [ - 'id' => $category->id, - 'name' => 'bar', - 'slug' => 'bar', - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_update_a_category() - { - $this->actingAsAdmin(); - - $category = Category::factory()->create([ - 'name' => 'foo', - 'slug' => 'foo', - ]); - - $this->adminPatch("/categories/{$category->id}", [ - 'name' => 'bar', - 'slug' => 'bar', - ]) - ->assertSessionMissing('errors') - ->assertStatus(403); - - $this->assertDatabaseHas('categories', [ - 'id' => $category->id, - 'name' => 'foo', - 'slug' => 'foo', - ]); - } - - #[Test] - public function it_can_show_a_category() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_categories'); - - $category = $this->getFactory(Category::class)->create(); - - $this->adminGet("/categories/{$category->id}") - ->assertStatus(200) - ->assertSee($category->name); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_view_a_category() - { - $this->actingAsAdmin(); - - $category = $this->getFactory(Category::class)->create(); - - $this->adminGet("/categories/{$category->id}") - ->assertStatus(403) - ->assertDontSee($category->name); - } - - #[Test] - public function it_can_delete_a_category() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('delete_categories'); - - $category = $this->getFactory(Category::class)->create(); - - $this->assertDatabaseHas('categories', [ - 'id' => $category->id, - ]); - - $this->adminDelete("/categories/{$category->id}") - ->assertRedirect(); - - $this->assertDatabaseMissing('categories', [ - 'id' => $category->id, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_delete_a_category() - { - $this->actingAsAdmin(); - - $category = $this->getFactory(Category::class)->create(); - - $this->assertDatabaseHas('categories', [ - 'id' => $category->id, - ]); - - $this->adminDelete("/categories/{$category->id}") - ->assertStatus(403); - - $this->assertDatabaseHas('categories', [ - 'id' => $category->id, - ]); - } - - #[Test] - public function it_can_bulk_delete_categories() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin(['view_categories', 'delete_categories']); - - $categories = $this->getFactory(Category::class)->count(3)->create(); - - $categories = $categories->map(function ($category) { - return collect($category->toArray()) - ->only('id') - ->all(); - }); - - $categories->each(function ($category) { - $this->assertDatabaseHas('categories', $category); - }); - - $categories_to_delete = $categories->slice(1); - - $this->adminPatch('/categories', [ - 'categories' => $categories_to_delete->pluck('id')->all(), - 'action' => 'delete', - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $categories_to_delete->each(function ($category) { - $this->assertDatabaseMissing('categories', $category); - }); - } - - #[Test] - public function it_does_not_allow_unauthorized_users_to_bulk_delete_categories() - { - $this->actingAsAdmin('view_categories'); - - $categories = $this->getFactory(Category::class)->count(3)->create(); - - $categories = $categories->map(function ($category) { - return collect($category->toArray()) - ->only('id') - ->all(); - }); - - $categories->each(function ($category) { - $this->assertDatabaseHas('categories', $category); - }); - - $categories_to_delete = $categories->slice(1); - - $this->adminPatch('/categories', [ - 'categories' => $categories_to_delete->pluck('id')->all(), - 'action' => 'delete', - ]) - ->assertStatus(403) - ->assertSessionMissing('errors'); - - $categories_to_delete->each(function ($category) { - $this->assertDatabaseHas('categories', $category); - }); - } - - #[Test] - public function it_can_validate_category_inputs() - { - $this->actingAsAdmin('edit_categories'); - - $this->adminPost('/categories', [ - 'name' => [], - 'slug' => [], - ]) - ->assertRedirect() - ->assertSessionHasErrors([ - 'name', - 'slug', - ]); - - $this->assertDatabaseMissing('categories', [ - 'name' => [], - 'slug' => [], - ]); - } -} diff --git a/tests/stubs/tests/OrdersControllerTest.stub b/tests/stubs/tests/OrdersControllerTest.stub deleted file mode 100644 index 5ff2847..0000000 --- a/tests/stubs/tests/OrdersControllerTest.stub +++ /dev/null @@ -1,410 +0,0 @@ -withoutExceptionHandling(); - - $this->actingAsAdmin('view_orders'); - - $order = $this->getFactory(Order::class)->create(); - - $this->adminGet('/orders') - ->assertStatus(200) - ->assertSee($order->order_no); - } - - #[Test] - public function it_can_filter_orders() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_orders'); - - $order = $this->getFactory(Order::class)->create(); - - $this->adminGet(add_query_arg([ - 'search' => $order->order_no, - 'date_field' => 'created_at', - 'date_from' => now()->subDay()->toDateTimeString(), - 'date_to' => now()->addDay()->toDateTimeString(), - ], '/orders')) - ->assertStatus(200) - ->assertSee($order->order_no); - } - - #[Test] - public function it_can_export_orders() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_orders'); - - $order = $this->getFactory(Order::class)->create(); - - Excel::fake(); - - $this->adminGet(add_query_arg(['download' => 1], '/orders')) - ->assertSuccessful(); - - Excel::assertDownloaded('orders.xlsx', function (OrdersExport $export) { - return $export->modelClass() == Order::class; - }); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_list_orders() - { - $this->actingAsAdmin(); - - $order = $this->getFactory(Order::class)->create(); - - $this->adminGet('/orders') - ->assertStatus(403) - ->assertDontSee($order->order_no); - } - - #[Test] - public function it_can_show_the_create_new_order_page() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_orders'); - - $this->adminGet('/orders/create') - ->assertStatus(200) - ->assertSee('New Order'); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_see_the_create_new_order_page() - { - $this->actingAsAdmin(); - - $this->adminGet('/orders/create') - ->assertStatus(403) - ->assertDontSee('New Order'); - } - - #[Test] - public function it_can_create_a_new_order() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_orders'); - - $order = $this->getFactory(Order::class)->make(); - - $this->adminPost('/orders', [ - 'order_no' => $order->order_no, - 'category' => $order->category_id, - 'product_slug' => $order->product_slug, - 'status' => $order->status->value, - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $this->assertDatabaseHas('orders', [ - 'order_no' => $order->order_no, - 'category_id' => $order->category_id, - 'product_slug' => $order->product_slug, - 'status' => $order->status->value, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_add_a_order() - { - $this->actingAsAdmin(); - - $order = $this->getFactory(Order::class)->make(); - - $this->adminPost('/orders', [ - 'order_no' => $order->order_no, - 'category' => $order->category_id, - 'product_slug' => $order->product_slug, - 'status' => $order->status->value, - ]) - ->assertSessionMissing('errors') - ->assertStatus(403); - - $this->assertDatabaseMissing('orders', [ - 'order_no' => $order->order_no, - 'category_id' => $order->category_id, - 'product_slug' => $order->product_slug, - 'status' => $order->status->value, - ]); - } - - #[Test] - public function it_can_show_the_edit_order_page() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_orders'); - - $order = $this->getFactory(Order::class)->create(); - - $this->adminGet("/orders/{$order->id}/edit") - ->assertStatus(200) - ->assertSee($order->order_no); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_see_the_edit_order_page() - { - $this->actingAsAdmin(); - - $order = $this->getFactory(Order::class)->create(); - - $this->adminGet("/orders/{$order->id}/edit") - ->assertStatus(403) - ->assertDontSee($order->order_no); - } - - #[Test] - public function it_can_update_a_order() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_orders'); - - $old_category = $this->getFactory(Category::class)->create(); - $old_product_slug = $this->getFactory(Product::class)->create(); - - $order = Order::factory()->create([ - 'order_no' => 'foo', - 'category_id' => $old_category->id, - 'product_slug' => $old_product_slug->slug, - 'status' => OrderStatuses::Pending->value, - ]); - - $new_category = $this->getFactory(Category::class)->create(); - $new_product_slug = $this->getFactory(Product::class)->create(); - - $this->adminPatch("/orders/{$order->id}", [ - 'order_no' => 'bar', - 'category' => $new_category->id, - 'product_slug' => $new_product_slug->slug, - 'status' => OrderStatuses::Paid->value, - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $this->assertDatabaseHas('orders', [ - 'id' => $order->id, - 'order_no' => 'bar', - 'category_id' => $new_category->id, - 'product_slug' => $new_product_slug->slug, - 'status' => OrderStatuses::Paid->value, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_update_a_order() - { - $this->actingAsAdmin(); - - $old_category = $this->getFactory(Category::class)->create(); - $old_product_slug = $this->getFactory(Product::class)->create(); - - $order = Order::factory()->create([ - 'order_no' => 'foo', - 'category_id' => $old_category->id, - 'product_slug' => $old_product_slug->slug, - 'status' => OrderStatuses::Pending->value, - ]); - - $new_category = $this->getFactory(Category::class)->create(); - $new_product_slug = $this->getFactory(Product::class)->create(); - - $this->adminPatch("/orders/{$order->id}", [ - 'order_no' => 'bar', - 'category' => $new_category->id, - 'product_slug' => $new_product_slug->slug, - 'status' => OrderStatuses::Paid->value, - ]) - ->assertSessionMissing('errors') - ->assertStatus(403); - - $this->assertDatabaseHas('orders', [ - 'id' => $order->id, - 'order_no' => 'foo', - 'category_id' => $old_category->id, - 'product_slug' => $old_product_slug->slug, - 'status' => OrderStatuses::Pending->value, - ]); - } - - #[Test] - public function it_can_show_a_order() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_orders'); - - $order = $this->getFactory(Order::class)->create(); - - $this->adminGet("/orders/{$order->id}") - ->assertStatus(200) - ->assertSee($order->order_no); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_view_a_order() - { - $this->actingAsAdmin(); - - $order = $this->getFactory(Order::class)->create(); - - $this->adminGet("/orders/{$order->id}") - ->assertStatus(403) - ->assertDontSee($order->order_no); - } - - #[Test] - public function it_can_delete_a_order() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('delete_orders'); - - $order = $this->getFactory(Order::class)->create(); - - $this->assertDatabaseHas('orders', [ - 'id' => $order->id, - ]); - - $this->adminDelete("/orders/{$order->id}") - ->assertRedirect(); - - $this->assertDatabaseMissing('orders', [ - 'id' => $order->id, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_delete_a_order() - { - $this->actingAsAdmin(); - - $order = $this->getFactory(Order::class)->create(); - - $this->assertDatabaseHas('orders', [ - 'id' => $order->id, - ]); - - $this->adminDelete("/orders/{$order->id}") - ->assertStatus(403); - - $this->assertDatabaseHas('orders', [ - 'id' => $order->id, - ]); - } - - #[Test] - public function it_can_bulk_delete_orders() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin(['view_orders', 'delete_orders']); - - $orders = $this->getFactory(Order::class)->count(3)->create(); - - $orders = $orders->map(function ($order) { - return collect($order->toArray()) - ->only('id') - ->all(); - }); - - $orders->each(function ($order) { - $this->assertDatabaseHas('orders', $order); - }); - - $orders_to_delete = $orders->slice(1); - - $this->adminPatch('/orders', [ - 'orders' => $orders_to_delete->pluck('id')->all(), - 'action' => 'delete', - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $orders_to_delete->each(function ($order) { - $this->assertDatabaseMissing('orders', $order); - }); - } - - #[Test] - public function it_does_not_allow_unauthorized_users_to_bulk_delete_orders() - { - $this->actingAsAdmin('view_orders'); - - $orders = $this->getFactory(Order::class)->count(3)->create(); - - $orders = $orders->map(function ($order) { - return collect($order->toArray()) - ->only('id') - ->all(); - }); - - $orders->each(function ($order) { - $this->assertDatabaseHas('orders', $order); - }); - - $orders_to_delete = $orders->slice(1); - - $this->adminPatch('/orders', [ - 'orders' => $orders_to_delete->pluck('id')->all(), - 'action' => 'delete', - ]) - ->assertStatus(403) - ->assertSessionMissing('errors'); - - $orders_to_delete->each(function ($order) { - $this->assertDatabaseHas('orders', $order); - }); - } - - #[Test] - public function it_can_validate_order_inputs() - { - $this->actingAsAdmin('edit_orders'); - - $this->adminPost('/orders', [ - 'order_no' => [], - 'category' => -1, - 'product_slug' => -1, - 'status' => [], - ]) - ->assertRedirect() - ->assertSessionHasErrors([ - 'order_no', - 'category', - 'product_slug', - 'status', - ]); - - $this->assertDatabaseMissing('orders', [ - 'order_no' => [], - 'category_id' => -1, - 'product_slug' => -1, - 'status' => [], - ]); - } -} diff --git a/tests/stubs/tests/ProductsControllerTest.stub b/tests/stubs/tests/ProductsControllerTest.stub deleted file mode 100644 index 0c65907..0000000 --- a/tests/stubs/tests/ProductsControllerTest.stub +++ /dev/null @@ -1,674 +0,0 @@ -withoutExceptionHandling(); - - $this->actingAsAdmin('view_products'); - - $product = $this->getFactory(Product::class)->create(); - - $this->adminGet('/products') - ->assertStatus(200) - ->assertSee($product->name); - } - - #[Test] - public function it_can_filter_products() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_products'); - - $product = $this->getFactory(Product::class)->create(); - - $this->adminGet(add_query_arg([ - 'search' => $product->name, - 'date_field' => 'created_at', - 'date_from' => now()->subDay()->toDateTimeString(), - 'date_to' => now()->addDay()->toDateTimeString(), - ], '/products')) - ->assertStatus(200) - ->assertSee($product->name); - } - - #[Test] - public function it_can_export_products() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_products'); - - $product = $this->getFactory(Product::class)->create(); - - Excel::fake(); - - $this->adminGet(add_query_arg(['download' => 1], '/products')) - ->assertSuccessful(); - - Excel::assertDownloaded('products.xlsx', function (ProductsExport $export) { - return $export->modelClass() == Product::class; - }); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_list_products() - { - $this->actingAsAdmin(); - - $product = $this->getFactory(Product::class)->create(); - - $this->adminGet('/products') - ->assertStatus(403) - ->assertDontSee($product->name); - } - - #[Test] - public function it_can_show_the_create_new_product_page() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_products'); - - $this->adminGet('/products/create') - ->assertStatus(200) - ->assertSee('New Product'); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_see_the_create_new_product_page() - { - $this->actingAsAdmin(); - - $this->adminGet('/products/create') - ->assertStatus(403) - ->assertDontSee('New Product'); - } - - #[Test] - public function it_can_create_a_new_product() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_products'); - - $product = $this->getFactory(Product::class)->make(); - - $this->adminPost('/products', [ - 'name' => $product->name, - 'address' => $product->address, - 'slug' => $product->slug, - 'description' => $product->description, - 'price' => $product->price, - 'stock' => $product->stock, - 'on_sale' => $product->on_sale, - 'features' => $product->features, - 'published_at' => $product->published_at, - 'expire_at' => $product->expire_at, - 'released_on' => $product->released_on, - 'sale_time' => $product->sale_time, - 'status' => $product->status, - 'category' => $product->category_id, - 'manufactured_year' => $product->manufactured_year, - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $this->assertDatabaseHas('products', [ - 'name' => $product->name, - 'address' => $product->address, - 'slug' => $product->slug, - 'description' => $product->description, - 'price' => $product->price, - 'stock' => $product->stock, - 'on_sale' => $product->on_sale, - 'features' => is_null($product->features) ? null : json_encode($product->features), - 'published_at' => $product->published_at, - 'expire_at' => $product->expire_at, - 'released_on' => $product->released_on, - 'sale_time' => $product->sale_time, - 'status' => $product->status, - 'category_id' => $product->category_id, - 'manufactured_year' => $product->manufactured_year, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_add_a_product() - { - $this->actingAsAdmin(); - - $product = $this->getFactory(Product::class)->make(); - - $this->adminPost('/products', [ - 'name' => $product->name, - 'address' => $product->address, - 'slug' => $product->slug, - 'description' => $product->description, - 'price' => $product->price, - 'stock' => $product->stock, - 'on_sale' => $product->on_sale, - 'features' => $product->features, - 'published_at' => $product->published_at, - 'expire_at' => $product->expire_at, - 'released_on' => $product->released_on, - 'sale_time' => $product->sale_time, - 'status' => $product->status, - 'category' => $product->category_id, - 'manufactured_year' => $product->manufactured_year, - ]) - ->assertSessionMissing('errors') - ->assertStatus(403); - - $this->assertDatabaseMissing('products', [ - 'name' => $product->name, - 'address' => $product->address, - 'slug' => $product->slug, - 'description' => $product->description, - 'price' => $product->price, - 'stock' => $product->stock, - 'on_sale' => $product->on_sale, - 'features' => is_null($product->features) ? null : json_encode($product->features), - 'published_at' => $product->published_at, - 'expire_at' => $product->expire_at, - 'released_on' => $product->released_on, - 'sale_time' => $product->sale_time, - 'status' => $product->status, - 'category_id' => $product->category_id, - 'manufactured_year' => $product->manufactured_year, - ]); - } - - #[Test] - public function it_can_show_the_edit_product_page() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_products'); - - $product = $this->getFactory(Product::class)->create(); - - $this->adminGet("/products/{$product->id}/edit") - ->assertStatus(200) - ->assertSee($product->name); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_see_the_edit_product_page() - { - $this->actingAsAdmin(); - - $product = $this->getFactory(Product::class)->create(); - - $this->adminGet("/products/{$product->id}/edit") - ->assertStatus(403) - ->assertDontSee($product->name); - } - - #[Test] - public function it_can_update_a_product() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_products'); - - $old_category = $this->getFactory(Category::class)->create(); - - $product = Product::factory()->create([ - 'name' => 'foo', - 'address' => 'foo', - 'slug' => 'foo', - 'description' => 'Lorem ipsum', - 'price' => 10.50, - 'stock' => 1, - 'on_sale' => true, - 'features' => ['apple'], - 'published_at' => '2024-02-12 14:54:00', - 'expire_at' => '2024-02-12 14:54:00', - 'released_on' => '2024-02-12', - 'sale_time' => '14:54:00', - 'status' => 'draft', - 'category_id' => $old_category->id, - 'manufactured_year' => 2024, - ]); - - $new_category = $this->getFactory(Category::class)->create(); - - $this->adminPatch("/products/{$product->id}", [ - 'name' => 'bar', - 'address' => 'bar', - 'slug' => 'bar', - 'description' => 'Itsu bitsum', - 'price' => 5.24, - 'stock' => 2, - 'on_sale' => false, - 'features' => ['orange'], - 'published_at' => '2023-01-11 13:30:00', - 'expire_at' => '2023-01-11 13:30:00', - 'released_on' => '2023-01-11', - 'sale_time' => '13:30:00', - 'status' => 'published', - 'category' => $new_category->id, - 'manufactured_year' => 2023, - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $this->assertDatabaseHas('products', [ - 'id' => $product->id, - 'name' => 'bar', - 'address' => 'bar', - 'slug' => 'bar', - 'description' => 'Itsu bitsum', - 'price' => 5.24, - 'stock' => 2, - 'on_sale' => false, - 'features' => json_encode(['orange']), - 'published_at' => '2023-01-11 13:30:00', - 'expire_at' => '2023-01-11 13:30:00', - 'released_on' => '2023-01-11', - 'sale_time' => '13:30:00', - 'status' => 'published', - 'category_id' => $new_category->id, - 'manufactured_year' => 2023, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_update_a_product() - { - $this->actingAsAdmin(); - - $old_category = $this->getFactory(Category::class)->create(); - - $product = Product::factory()->create([ - 'name' => 'foo', - 'address' => 'foo', - 'slug' => 'foo', - 'description' => 'Lorem ipsum', - 'price' => 10.50, - 'stock' => 1, - 'on_sale' => true, - 'features' => ['apple'], - 'published_at' => '2024-02-12 14:54:00', - 'expire_at' => '2024-02-12 14:54:00', - 'released_on' => '2024-02-12', - 'sale_time' => '14:54:00', - 'status' => 'draft', - 'category_id' => $old_category->id, - 'manufactured_year' => 2024, - ]); - - $new_category = $this->getFactory(Category::class)->create(); - - $this->adminPatch("/products/{$product->id}", [ - 'name' => 'bar', - 'address' => 'bar', - 'slug' => 'bar', - 'description' => 'Itsu bitsum', - 'price' => 5.24, - 'stock' => 2, - 'on_sale' => false, - 'features' => ['orange'], - 'published_at' => '2023-01-11 13:30:00', - 'expire_at' => '2023-01-11 13:30:00', - 'released_on' => '2023-01-11', - 'sale_time' => '13:30:00', - 'status' => 'published', - 'category' => $new_category->id, - 'manufactured_year' => 2023, - ]) - ->assertSessionMissing('errors') - ->assertStatus(403); - - $this->assertDatabaseHas('products', [ - 'id' => $product->id, - 'name' => 'foo', - 'address' => 'foo', - 'slug' => 'foo', - 'description' => 'Lorem ipsum', - 'price' => 10.50, - 'stock' => 1, - 'on_sale' => true, - 'features' => json_encode(['apple']), - 'published_at' => '2024-02-12 14:54:00', - 'expire_at' => '2024-02-12 14:54:00', - 'released_on' => '2024-02-12', - 'sale_time' => '14:54:00', - 'status' => 'draft', - 'category_id' => $old_category->id, - 'manufactured_year' => 2024, - ]); - } - - #[Test] - public function it_can_show_a_product() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_products'); - - $product = $this->getFactory(Product::class)->create(); - - $this->adminGet("/products/{$product->id}") - ->assertStatus(200) - ->assertSee($product->name); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_view_a_product() - { - $this->actingAsAdmin(); - - $product = $this->getFactory(Product::class)->create(); - - $this->adminGet("/products/{$product->id}") - ->assertStatus(403) - ->assertDontSee($product->name); - } - - #[Test] - public function it_can_soft_delete_a_product() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('delete_products'); - - $product = $this->getFactory(Product::class)->create(); - - $this->assertDatabaseHas('products', [ - 'id' => $product->id, - ]); - - $this->adminDelete("/products/{$product->id}") - ->assertRedirect(); - - $this->assertSoftDeleted('products', [ - 'id' => $product->id, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_soft_delete_a_product() - { - $this->actingAsAdmin(); - - $product = $this->getFactory(Product::class)->create(); - - $this->assertDatabaseHas('products', [ - 'id' => $product->id, - ]); - - $this->adminDelete("/products/{$product->id}") - ->assertStatus(403); - - $this->assertDatabaseHas('products', [ - 'id' => $product->id, - ]); - } - - #[Test] - public function it_can_force_delete_a_product() - { - $this->withoutExceptionHandling(); - - $product = $this->getFactory(Product::class)->create(); - $product->delete(); - - // sanity check - $this->assertSoftDeleted('products', [ - 'id' => $product->id, - ]); - - $this->actingAsAdmin('force_delete_products'); - - $this->adminDelete("/products/{$product->id}/force-delete") - ->assertRedirect(); - - $this->assertDatabaseMissing('products', [ - 'id' => $product->id, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_force_delete_a_product() - { - $product = $this->getFactory(Product::class)->create(); - $product->delete(); - - // sanity check - $this->assertSoftDeleted('products', [ - 'id' => $product->id, - ]); - - $this->actingAsAdmin(); - - $this->adminDelete("/products/{$product->id}/force-delete") - ->assertStatus(403); - - $this->assertSoftDeleted('products', [ - 'id' => $product->id, - ]); - } - - #[Test] - public function it_can_restore_a_soft_deleted_product() - { - $this->withoutExceptionHandling(); - - $product = $this->getFactory(Product::class)->create(); - $product->delete(); - - // sanity check - $this->assertSoftDeleted('products', [ - 'id' => $product->id, - ]); - - $this->actingAsAdmin('delete_products'); - - $this->adminPost("/products/{$product->id}/restore") - ->assertRedirect(); - - $this->assertDatabaseHas('products', [ - 'id' => $product->id, - 'deleted_at' => null, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_restore_a_product() - { - $product = $this->getFactory(Product::class)->create(); - $product->delete(); - - // sanity check - $this->assertSoftDeleted('products', [ - 'id' => $product->id, - ]); - - $this->actingAsAdmin(); - - $this->adminPost("/products/{$product->id}/restore") - ->assertStatus(403); - - $this->assertSoftDeleted('products', [ - 'id' => $product->id, - ]); - } - - #[Test] - public function it_can_list_soft_deleted_products() - { - $this->withoutExceptionHandling(); - - $product = $this->getFactory(Product::class)->create(); - $product->delete(); - - // sanity check - $this->assertSoftDeleted('products', [ - 'id' => $product->id, - ]); - - $this->actingAsAdmin('delete_products'); - - $this->adminGet("/products/trash") - ->assertStatus(200) - ->assertSee($product->name); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_list_soft_deleted_products() - { - $product = $this->getFactory(Product::class)->create(); - $product->delete(); - - // sanity check - $this->assertSoftDeleted('products', [ - 'id' => $product->id, - ]); - - $this->actingAsAdmin(); - - $this->adminGet("/products/trash") - ->assertStatus(403) - ->assertDontSee($product->name); - } - - #[Test] - public function it_can_bulk_soft_delete_products() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin(['view_products', 'delete_products']); - - $products = $this->getFactory(Product::class)->count(3)->create(); - - $products = $products->map(function ($product) { - return collect($product->toArray()) - ->only('id') - ->all(); - }); - - $products->each(function ($product) { - $this->assertDatabaseHas('products', $product); - }); - - $products_to_delete = $products->slice(1); - - $this->adminPatch('/products', [ - 'products' => $products_to_delete->pluck('id')->all(), - 'action' => 'delete', - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $products_to_delete->each(function ($product) { - $this->assertSoftDeleted('products', $product); - }); - } - - #[Test] - public function it_does_not_allow_unauthorized_users_to_bulk_soft_delete_products() - { - $this->actingAsAdmin('view_products'); - - $products = $this->getFactory(Product::class)->count(3)->create(); - - $products = $products->map(function ($product) { - return collect($product->toArray()) - ->only('id') - ->all(); - }); - - $products->each(function ($product) { - $this->assertDatabaseHas('products', $product); - }); - - $products_to_delete = $products->slice(1); - - $this->adminPatch('/products', [ - 'products' => $products_to_delete->pluck('id')->all(), - 'action' => 'delete', - ]) - ->assertStatus(403) - ->assertSessionMissing('errors'); - - $products_to_delete->each(function ($product) { - $this->assertDatabaseHas('products', $product); - }); - } - - #[Test] - public function it_can_validate_product_inputs() - { - $this->actingAsAdmin('edit_products'); - - $this->adminPost('/products', [ - 'name' => [], - 'address' => [], - 'slug' => [], - 'description' => [], - 'price' => 'foo', - 'stock' => 'foo', - 'on_sale' => 'foo', - 'features' => 'foo', - 'published_at' => 'foo', - 'expire_at' => 'foo', - 'released_on' => 'foo', - 'sale_time' => 'foo', - 'status' => [], - 'category' => -1, - 'manufactured_year' => -1, - ]) - ->assertRedirect() - ->assertSessionHasErrors([ - 'name', - 'address', - 'slug', - 'description', - 'price', - 'stock', - 'on_sale', - 'features', - 'published_at', - 'expire_at', - 'released_on', - 'sale_time', - 'status', - 'category', - 'manufactured_year', - ]); - - $this->assertDatabaseMissing('products', [ - 'name' => [], - 'address' => [], - 'slug' => [], - 'description' => [], - 'price' => 'foo', - 'stock' => 'foo', - 'on_sale' => 'foo', - 'features' => json_encode('foo'), - 'published_at' => 'foo', - 'expire_at' => 'foo', - 'released_on' => 'foo', - 'sale_time' => 'foo', - 'status' => [], - 'category_id' => -1, - 'manufactured_year' => -1, - ]); - } -} diff --git a/tests/stubs/tests/SingleValueEnumOrdersControllerTest.stub b/tests/stubs/tests/SingleValueEnumOrdersControllerTest.stub deleted file mode 100644 index 248ade0..0000000 --- a/tests/stubs/tests/SingleValueEnumOrdersControllerTest.stub +++ /dev/null @@ -1,410 +0,0 @@ -withoutExceptionHandling(); - - $this->actingAsAdmin('view_single_value_enum_orders'); - - $single_value_enum_order = $this->getFactory(SingleValueEnumOrder::class)->create(); - - $this->adminGet('/single-value-enum-orders') - ->assertStatus(200) - ->assertSee($single_value_enum_order->order_no); - } - - #[Test] - public function it_can_filter_single_value_enum_orders() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_single_value_enum_orders'); - - $single_value_enum_order = $this->getFactory(SingleValueEnumOrder::class)->create(); - - $this->adminGet(add_query_arg([ - 'search' => $single_value_enum_order->order_no, - 'date_field' => 'created_at', - 'date_from' => now()->subDay()->toDateTimeString(), - 'date_to' => now()->addDay()->toDateTimeString(), - ], '/single-value-enum-orders')) - ->assertStatus(200) - ->assertSee($single_value_enum_order->order_no); - } - - #[Test] - public function it_can_export_single_value_enum_orders() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_single_value_enum_orders'); - - $single_value_enum_order = $this->getFactory(SingleValueEnumOrder::class)->create(); - - Excel::fake(); - - $this->adminGet(add_query_arg(['download' => 1], '/single-value-enum-orders')) - ->assertSuccessful(); - - Excel::assertDownloaded('single-value-enum-orders.xlsx', function (SingleValueEnumOrdersExport $export) { - return $export->modelClass() == SingleValueEnumOrder::class; - }); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_list_single_value_enum_orders() - { - $this->actingAsAdmin(); - - $single_value_enum_order = $this->getFactory(SingleValueEnumOrder::class)->create(); - - $this->adminGet('/single-value-enum-orders') - ->assertStatus(403) - ->assertDontSee($single_value_enum_order->order_no); - } - - #[Test] - public function it_can_show_the_create_new_single_value_enum_order_page() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_single_value_enum_orders'); - - $this->adminGet('/single-value-enum-orders/create') - ->assertStatus(200) - ->assertSee('New Single Value Enum Order'); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_see_the_create_new_single_value_enum_order_page() - { - $this->actingAsAdmin(); - - $this->adminGet('/single-value-enum-orders/create') - ->assertStatus(403) - ->assertDontSee('New Single Value Enum Order'); - } - - #[Test] - public function it_can_create_a_new_single_value_enum_order() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_single_value_enum_orders'); - - $single_value_enum_order = $this->getFactory(SingleValueEnumOrder::class)->make(); - - $this->adminPost('/single-value-enum-orders', [ - 'order_no' => $single_value_enum_order->order_no, - 'category' => $single_value_enum_order->category_id, - 'product_slug' => $single_value_enum_order->product_slug, - 'status' => $single_value_enum_order->status->value, - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $this->assertDatabaseHas('single_value_enum_orders', [ - 'order_no' => $single_value_enum_order->order_no, - 'category_id' => $single_value_enum_order->category_id, - 'product_slug' => $single_value_enum_order->product_slug, - 'status' => $single_value_enum_order->status->value, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_add_a_single_value_enum_order() - { - $this->actingAsAdmin(); - - $single_value_enum_order = $this->getFactory(SingleValueEnumOrder::class)->make(); - - $this->adminPost('/single-value-enum-orders', [ - 'order_no' => $single_value_enum_order->order_no, - 'category' => $single_value_enum_order->category_id, - 'product_slug' => $single_value_enum_order->product_slug, - 'status' => $single_value_enum_order->status->value, - ]) - ->assertSessionMissing('errors') - ->assertStatus(403); - - $this->assertDatabaseMissing('single_value_enum_orders', [ - 'order_no' => $single_value_enum_order->order_no, - 'category_id' => $single_value_enum_order->category_id, - 'product_slug' => $single_value_enum_order->product_slug, - 'status' => $single_value_enum_order->status->value, - ]); - } - - #[Test] - public function it_can_show_the_edit_single_value_enum_order_page() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_single_value_enum_orders'); - - $single_value_enum_order = $this->getFactory(SingleValueEnumOrder::class)->create(); - - $this->adminGet("/single-value-enum-orders/{$single_value_enum_order->id}/edit") - ->assertStatus(200) - ->assertSee($single_value_enum_order->order_no); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_see_the_edit_single_value_enum_order_page() - { - $this->actingAsAdmin(); - - $single_value_enum_order = $this->getFactory(SingleValueEnumOrder::class)->create(); - - $this->adminGet("/single-value-enum-orders/{$single_value_enum_order->id}/edit") - ->assertStatus(403) - ->assertDontSee($single_value_enum_order->order_no); - } - - #[Test] - public function it_can_update_a_single_value_enum_order() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('edit_single_value_enum_orders'); - - $old_category = $this->getFactory(Category::class)->create(); - $old_product_slug = $this->getFactory(Product::class)->create(); - - $single_value_enum_order = SingleValueEnumOrder::factory()->create([ - 'order_no' => 'foo', - 'category_id' => $old_category->id, - 'product_slug' => $old_product_slug->slug, - 'status' => SingleCaseOrderStatuses::Pending->value, - ]); - - $new_category = $this->getFactory(Category::class)->create(); - $new_product_slug = $this->getFactory(Product::class)->create(); - - $this->adminPatch("/single-value-enum-orders/{$single_value_enum_order->id}", [ - 'order_no' => 'bar', - 'category' => $new_category->id, - 'product_slug' => $new_product_slug->slug, - 'status' => SingleCaseOrderStatuses::Pending->value, - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $this->assertDatabaseHas('single_value_enum_orders', [ - 'id' => $single_value_enum_order->id, - 'order_no' => 'bar', - 'category_id' => $new_category->id, - 'product_slug' => $new_product_slug->slug, - 'status' => SingleCaseOrderStatuses::Pending->value, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_update_a_single_value_enum_order() - { - $this->actingAsAdmin(); - - $old_category = $this->getFactory(Category::class)->create(); - $old_product_slug = $this->getFactory(Product::class)->create(); - - $single_value_enum_order = SingleValueEnumOrder::factory()->create([ - 'order_no' => 'foo', - 'category_id' => $old_category->id, - 'product_slug' => $old_product_slug->slug, - 'status' => SingleCaseOrderStatuses::Pending->value, - ]); - - $new_category = $this->getFactory(Category::class)->create(); - $new_product_slug = $this->getFactory(Product::class)->create(); - - $this->adminPatch("/single-value-enum-orders/{$single_value_enum_order->id}", [ - 'order_no' => 'bar', - 'category' => $new_category->id, - 'product_slug' => $new_product_slug->slug, - 'status' => SingleCaseOrderStatuses::Pending->value, - ]) - ->assertSessionMissing('errors') - ->assertStatus(403); - - $this->assertDatabaseHas('single_value_enum_orders', [ - 'id' => $single_value_enum_order->id, - 'order_no' => 'foo', - 'category_id' => $old_category->id, - 'product_slug' => $old_product_slug->slug, - 'status' => SingleCaseOrderStatuses::Pending->value, - ]); - } - - #[Test] - public function it_can_show_a_single_value_enum_order() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('view_single_value_enum_orders'); - - $single_value_enum_order = $this->getFactory(SingleValueEnumOrder::class)->create(); - - $this->adminGet("/single-value-enum-orders/{$single_value_enum_order->id}") - ->assertStatus(200) - ->assertSee($single_value_enum_order->order_no); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_view_a_single_value_enum_order() - { - $this->actingAsAdmin(); - - $single_value_enum_order = $this->getFactory(SingleValueEnumOrder::class)->create(); - - $this->adminGet("/single-value-enum-orders/{$single_value_enum_order->id}") - ->assertStatus(403) - ->assertDontSee($single_value_enum_order->order_no); - } - - #[Test] - public function it_can_delete_a_single_value_enum_order() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin('delete_single_value_enum_orders'); - - $single_value_enum_order = $this->getFactory(SingleValueEnumOrder::class)->create(); - - $this->assertDatabaseHas('single_value_enum_orders', [ - 'id' => $single_value_enum_order->id, - ]); - - $this->adminDelete("/single-value-enum-orders/{$single_value_enum_order->id}") - ->assertRedirect(); - - $this->assertDatabaseMissing('single_value_enum_orders', [ - 'id' => $single_value_enum_order->id, - ]); - } - - #[Test] - public function it_does_not_allow_an_unauthorized_user_to_delete_a_single_value_enum_order() - { - $this->actingAsAdmin(); - - $single_value_enum_order = $this->getFactory(SingleValueEnumOrder::class)->create(); - - $this->assertDatabaseHas('single_value_enum_orders', [ - 'id' => $single_value_enum_order->id, - ]); - - $this->adminDelete("/single-value-enum-orders/{$single_value_enum_order->id}") - ->assertStatus(403); - - $this->assertDatabaseHas('single_value_enum_orders', [ - 'id' => $single_value_enum_order->id, - ]); - } - - #[Test] - public function it_can_bulk_delete_single_value_enum_orders() - { - $this->withoutExceptionHandling(); - - $this->actingAsAdmin(['view_single_value_enum_orders', 'delete_single_value_enum_orders']); - - $single_value_enum_orders = $this->getFactory(SingleValueEnumOrder::class)->count(3)->create(); - - $single_value_enum_orders = $single_value_enum_orders->map(function ($single_value_enum_order) { - return collect($single_value_enum_order->toArray()) - ->only('id') - ->all(); - }); - - $single_value_enum_orders->each(function ($single_value_enum_order) { - $this->assertDatabaseHas('single_value_enum_orders', $single_value_enum_order); - }); - - $single_value_enum_orders_to_delete = $single_value_enum_orders->slice(1); - - $this->adminPatch('/single-value-enum-orders', [ - 'single_value_enum_orders' => $single_value_enum_orders_to_delete->pluck('id')->all(), - 'action' => 'delete', - ]) - ->assertRedirect() - ->assertSessionMissing('errors'); - - $single_value_enum_orders_to_delete->each(function ($single_value_enum_order) { - $this->assertDatabaseMissing('single_value_enum_orders', $single_value_enum_order); - }); - } - - #[Test] - public function it_does_not_allow_unauthorized_users_to_bulk_delete_single_value_enum_orders() - { - $this->actingAsAdmin('view_single_value_enum_orders'); - - $single_value_enum_orders = $this->getFactory(SingleValueEnumOrder::class)->count(3)->create(); - - $single_value_enum_orders = $single_value_enum_orders->map(function ($single_value_enum_order) { - return collect($single_value_enum_order->toArray()) - ->only('id') - ->all(); - }); - - $single_value_enum_orders->each(function ($single_value_enum_order) { - $this->assertDatabaseHas('single_value_enum_orders', $single_value_enum_order); - }); - - $single_value_enum_orders_to_delete = $single_value_enum_orders->slice(1); - - $this->adminPatch('/single-value-enum-orders', [ - 'single_value_enum_orders' => $single_value_enum_orders_to_delete->pluck('id')->all(), - 'action' => 'delete', - ]) - ->assertStatus(403) - ->assertSessionMissing('errors'); - - $single_value_enum_orders_to_delete->each(function ($single_value_enum_order) { - $this->assertDatabaseHas('single_value_enum_orders', $single_value_enum_order); - }); - } - - #[Test] - public function it_can_validate_single_value_enum_order_inputs() - { - $this->actingAsAdmin('edit_single_value_enum_orders'); - - $this->adminPost('/single-value-enum-orders', [ - 'order_no' => [], - 'category' => -1, - 'product_slug' => -1, - 'status' => [], - ]) - ->assertRedirect() - ->assertSessionHasErrors([ - 'order_no', - 'category', - 'product_slug', - 'status', - ]); - - $this->assertDatabaseMissing('single_value_enum_orders', [ - 'order_no' => [], - 'category_id' => -1, - 'product_slug' => -1, - 'status' => [], - ]); - } -} diff --git a/tests/stubs/tests/_correctDbValues.stub b/tests/stubs/tests/_correctDbValues.stub deleted file mode 100644 index 58657b4..0000000 --- a/tests/stubs/tests/_correctDbValues.stub +++ /dev/null @@ -1,15 +0,0 @@ - 'name' => 'foo', - 'address' => 'foo', - 'slug' => 'foo', - 'description' => 'Lorem ipsum', - 'price' => 10.50, - 'stock' => 1, - 'on_sale' => true, - 'features' => json_encode(['apple']), - 'published_at' => '2024-02-12 14:54:00', - 'expire_at' => '2024-02-12 14:54:00', - 'released_on' => '2024-02-12', - 'sale_time' => '14:54:00', - 'status' => 'draft', - 'category_id' => $old_category->id, - 'manufactured_year' => 2024, diff --git a/tests/stubs/tests/_correctInputs.stub b/tests/stubs/tests/_correctInputs.stub deleted file mode 100644 index 778e4cf..0000000 --- a/tests/stubs/tests/_correctInputs.stub +++ /dev/null @@ -1,15 +0,0 @@ - 'name' => 'foo', - 'address' => 'foo', - 'slug' => 'foo', - 'description' => 'Lorem ipsum', - 'price' => 10.50, - 'stock' => 1, - 'on_sale' => true, - 'features' => ['apple'], - 'published_at' => '2024-02-12 14:54:00', - 'expire_at' => '2024-02-12 14:54:00', - 'released_on' => '2024-02-12', - 'sale_time' => '14:54:00', - 'status' => 'draft', - 'category' => $old_category->id, - 'manufactured_year' => 2024, diff --git a/tests/stubs/tests/_differentCorrectDbValues.stub b/tests/stubs/tests/_differentCorrectDbValues.stub deleted file mode 100644 index f74f406..0000000 --- a/tests/stubs/tests/_differentCorrectDbValues.stub +++ /dev/null @@ -1,15 +0,0 @@ - 'name' => 'bar', - 'address' => 'bar', - 'slug' => 'bar', - 'description' => 'Itsu bitsum', - 'price' => 5.24, - 'stock' => 2, - 'on_sale' => false, - 'features' => json_encode(['orange']), - 'published_at' => '2023-01-11 13:30:00', - 'expire_at' => '2023-01-11 13:30:00', - 'released_on' => '2023-01-11', - 'sale_time' => '13:30:00', - 'status' => 'published', - 'category_id' => $new_category->id, - 'manufactured_year' => 2023, diff --git a/tests/stubs/tests/_differentCorrectInputs.stub b/tests/stubs/tests/_differentCorrectInputs.stub deleted file mode 100644 index 6727c3d..0000000 --- a/tests/stubs/tests/_differentCorrectInputs.stub +++ /dev/null @@ -1,15 +0,0 @@ - 'name' => 'bar', - 'address' => 'bar', - 'slug' => 'bar', - 'description' => 'Itsu bitsum', - 'price' => 5.24, - 'stock' => 2, - 'on_sale' => false, - 'features' => ['orange'], - 'published_at' => '2023-01-11 13:30:00', - 'expire_at' => '2023-01-11 13:30:00', - 'released_on' => '2023-01-11', - 'sale_time' => '13:30:00', - 'status' => 'published', - 'category' => $new_category->id, - 'manufactured_year' => 2023, diff --git a/tests/stubs/tests/_errors.stub b/tests/stubs/tests/_errors.stub deleted file mode 100644 index 90c1b62..0000000 --- a/tests/stubs/tests/_errors.stub +++ /dev/null @@ -1,15 +0,0 @@ - 'name', - 'address', - 'slug', - 'description', - 'price', - 'stock', - 'on_sale', - 'features', - 'published_at', - 'expire_at', - 'released_on', - 'sale_time', - 'status', - 'category', - 'manufactured_year', diff --git a/tests/stubs/tests/_factoryDbValues.stub b/tests/stubs/tests/_factoryDbValues.stub deleted file mode 100644 index d456fe1..0000000 --- a/tests/stubs/tests/_factoryDbValues.stub +++ /dev/null @@ -1,15 +0,0 @@ - 'name' => $product->name, - 'address' => $product->address, - 'slug' => $product->slug, - 'description' => $product->description, - 'price' => $product->price, - 'stock' => $product->stock, - 'on_sale' => $product->on_sale, - 'features' => is_null($product->features) ? null : json_encode($product->features), - 'published_at' => $product->published_at, - 'expire_at' => $product->expire_at, - 'released_on' => $product->released_on, - 'sale_time' => $product->sale_time, - 'status' => $product->status, - 'category_id' => $product->category_id, - 'manufactured_year' => $product->manufactured_year, diff --git a/tests/stubs/tests/_factoryInputs.stub b/tests/stubs/tests/_factoryInputs.stub deleted file mode 100644 index 75979da..0000000 --- a/tests/stubs/tests/_factoryInputs.stub +++ /dev/null @@ -1,15 +0,0 @@ - 'name' => $product->name, - 'address' => $product->address, - 'slug' => $product->slug, - 'description' => $product->description, - 'price' => $product->price, - 'stock' => $product->stock, - 'on_sale' => $product->on_sale, - 'features' => $product->features, - 'published_at' => $product->published_at, - 'expire_at' => $product->expire_at, - 'released_on' => $product->released_on, - 'sale_time' => $product->sale_time, - 'status' => $product->status, - 'category' => $product->category_id, - 'manufactured_year' => $product->manufactured_year, diff --git a/tests/stubs/tests/_wrongDbValues.stub b/tests/stubs/tests/_wrongDbValues.stub deleted file mode 100644 index f1ce9c5..0000000 --- a/tests/stubs/tests/_wrongDbValues.stub +++ /dev/null @@ -1,15 +0,0 @@ - 'name' => [], - 'address' => [], - 'slug' => [], - 'description' => [], - 'price' => 'foo', - 'stock' => 'foo', - 'on_sale' => 'foo', - 'features' => json_encode('foo'), - 'published_at' => 'foo', - 'expire_at' => 'foo', - 'released_on' => 'foo', - 'sale_time' => 'foo', - 'status' => [], - 'category_id' => -1, - 'manufactured_year' => -1, diff --git a/tests/stubs/tests/_wrongInputs.stub b/tests/stubs/tests/_wrongInputs.stub deleted file mode 100644 index 5edc79a..0000000 --- a/tests/stubs/tests/_wrongInputs.stub +++ /dev/null @@ -1,15 +0,0 @@ - 'name' => [], - 'address' => [], - 'slug' => [], - 'description' => [], - 'price' => 'foo', - 'stock' => 'foo', - 'on_sale' => 'foo', - 'features' => 'foo', - 'published_at' => 'foo', - 'expire_at' => 'foo', - 'released_on' => 'foo', - 'sale_time' => 'foo', - 'status' => [], - 'category' => -1, - 'manufactured_year' => -1, diff --git a/tests/stubs/views/categories/_actions.blade.php b/tests/stubs/views/categories/_actions.blade.php deleted file mode 100644 index 3179c38..0000000 --- a/tests/stubs/views/categories/_actions.blade.php +++ /dev/null @@ -1,41 +0,0 @@ -
- @if(isset($category)) - @can('delete', $category) - - {{ __('Delete') }} - - @endcan - - @can('viewLogs', $category) - - {{ __('View Logs') }} - - @endcan - - @can('update', $category) - - {{ __('Edit') }} - - @endcan - - @can('view', $category) - - {{ __('View') }} - - @endcan - @endif - - @can('create', App\Models\Category::class) - - {{ __('Add New') }} - - @endcan - - @can('viewAny', App\Models\Category::class) - - {{ __('View All') }} - - @endcan -
diff --git a/tests/stubs/views/categories/_filter.blade.php b/tests/stubs/views/categories/_filter.blade.php deleted file mode 100644 index 8857bf4..0000000 --- a/tests/stubs/views/categories/_filter.blade.php +++ /dev/null @@ -1,30 +0,0 @@ - -
-
- -
-
- -
-
- -
-
- -
-
-
-
- -
-
- -
-
-
diff --git a/tests/stubs/views/categories/_form.blade.php b/tests/stubs/views/categories/_form.blade.php deleted file mode 100644 index 2371ceb..0000000 --- a/tests/stubs/views/categories/_form.blade.php +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - {{ __('Save') }} - - - - {{ __('Cancel') }} - - - - -@include('admin.components.floating-submit') diff --git a/tests/stubs/views/categories/_list.blade.php b/tests/stubs/views/categories/_list.blade.php deleted file mode 100644 index 59f9b1d..0000000 --- a/tests/stubs/views/categories/_list.blade.php +++ /dev/null @@ -1,33 +0,0 @@ -@foreach($categories as $category) - - - - {!! $category->admin_link !!} -
- {{ __('ID: :id', ['id' => $category->id]) }} - - @can('view', $category) - - {{ __('View') }} - - @endcan - - @can('update', $category) - - {{ __('Edit') }} - - @endcan - - @can('delete', $category) - - {{ __('Delete') }} - - @endcan -
-
- - - -
-@endforeach diff --git a/tests/stubs/views/categories/_table.blade.php b/tests/stubs/views/categories/_table.blade.php deleted file mode 100644 index aa0bda9..0000000 --- a/tests/stubs/views/categories/_table.blade.php +++ /dev/null @@ -1,35 +0,0 @@ - - - @if(empty($no_bulk)) - - @include('admin.categories._bulk') - - @endif - - - - - - - - @if($categories->isEmpty()) - - {{ __('No matching categories found.') }} - - @else - @include('admin.categories._list') - @endif - - - @if(empty($no_pagination)) - - {{ $categories->links('forms::material-admin-26.pagination') }} - - @endif - - diff --git a/tests/stubs/views/categories/index.blade.php b/tests/stubs/views/categories/index.blade.php deleted file mode 100644 index 1a83396..0000000 --- a/tests/stubs/views/categories/index.blade.php +++ /dev/null @@ -1,29 +0,0 @@ -@extends('admin.categories.categories') - -@section('page-subheading') - {{ $title }} -@endsection - -@section('content') - @if($categories->isNotEmpty() || App\Models\Category::exists()) -
- - @include('admin.categories._filter') - - - @include('admin.categories._table') -
- @else - - @endif -@endsection diff --git a/tests/stubs/views/orders/_details.blade.php b/tests/stubs/views/orders/_details.blade.php deleted file mode 100644 index c38ab7f..0000000 --- a/tests/stubs/views/orders/_details.blade.php +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/tests/stubs/views/orders/_form.blade.php b/tests/stubs/views/orders/_form.blade.php deleted file mode 100644 index 697f2bb..0000000 --- a/tests/stubs/views/orders/_form.blade.php +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - {{ __('Save') }} - - - - {{ __('Cancel') }} - - - - -@include('admin.components.floating-submit') diff --git a/tests/stubs/views/orders/_list.blade.php b/tests/stubs/views/orders/_list.blade.php deleted file mode 100644 index 9d6676d..0000000 --- a/tests/stubs/views/orders/_list.blade.php +++ /dev/null @@ -1,37 +0,0 @@ -@foreach($orders as $order) - - - - {!! $order->admin_link !!} -
- {{ __('ID: :id', ['id' => $order->id]) }} - - @can('view', $order) - - {{ __('View') }} - - @endcan - - @can('update', $order) - - {{ __('Edit') }} - - @endcan - - @can('delete', $order) - - {{ __('Delete') }} - - @endcan -
-
- - - - - - - -
-@endforeach diff --git a/tests/stubs/views/products/_actions.blade.php b/tests/stubs/views/products/_actions.blade.php deleted file mode 100644 index 4616512..0000000 --- a/tests/stubs/views/products/_actions.blade.php +++ /dev/null @@ -1,48 +0,0 @@ -
- @if(isset($product)) - @can('delete', $product) - - {{ __('Delete') }} - - @endcan - - @can('viewLogs', $product) - - {{ __('View Logs') }} - - @endcan - - @can('update', $product) - - {{ __('Edit') }} - - @endcan - - @can('view', $product) - - {{ __('View') }} - - @endcan - @endif - - @can('create', App\Models\Product::class) - - {{ __('Add New') }} - - @endcan - - @can('viewTrash', App\Models\Product::class) - - {{ __('Trash') }} - - @endcan - - @can('viewAny', App\Models\Product::class) - - {{ __('View All') }} - - @endcan -
diff --git a/tests/stubs/views/products/_bulk.blade.php b/tests/stubs/views/products/_bulk.blade.php deleted file mode 100644 index 5e7f544..0000000 --- a/tests/stubs/views/products/_bulk.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -@php - $actions = []; - - if (auth()->user()->can('delete_products')) { - $actions['delete'] = __('Delete'); - } -@endphp - - diff --git a/tests/stubs/views/products/_columns.blade.stub b/tests/stubs/views/products/_columns.blade.stub deleted file mode 100644 index 912e049..0000000 --- a/tests/stubs/views/products/_columns.blade.stub +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tests/stubs/views/products/_details.blade.php b/tests/stubs/views/products/_details.blade.php deleted file mode 100644 index e1fd1e6..0000000 --- a/tests/stubs/views/products/_details.blade.php +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tests/stubs/views/products/_filter.blade.php b/tests/stubs/views/products/_filter.blade.php deleted file mode 100644 index 85606a1..0000000 --- a/tests/stubs/views/products/_filter.blade.php +++ /dev/null @@ -1,30 +0,0 @@ - -
-
- -
-
- -
-
- -
-
- -
-
-
-
- -
-
- -
-
-
diff --git a/tests/stubs/views/products/_form.blade.php b/tests/stubs/views/products/_form.blade.php deleted file mode 100644 index 8a7a883..0000000 --- a/tests/stubs/views/products/_form.blade.php +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ __('Save') }} - - - - {{ __('Cancel') }} - - - - -@include('admin.components.floating-submit') diff --git a/tests/stubs/views/products/_list.blade.php b/tests/stubs/views/products/_list.blade.php deleted file mode 100644 index 94814d7..0000000 --- a/tests/stubs/views/products/_list.blade.php +++ /dev/null @@ -1,75 +0,0 @@ -@foreach($products as $product) - - - - {!! $product->admin_link !!} -
- {{ __('ID: :id', ['id' => $product->id]) }} - - @if($product->trashed()) - @can('forceDelete', $product) - - {{ __('Delete Permanently') }} - - @endcan - - @can('restore', $product) - - {{ __('Restore') }} - - @endcan - @else - @can('view', $product) - - {{ __('View') }} - - @endcan - - @can('update', $product) - - {{ __('Edit') }} - - @endcan - - @can('delete', $product) - - {{ __('Delete') }} - - @endcan - @endif -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-@endforeach diff --git a/tests/stubs/views/products/_table.blade.php b/tests/stubs/views/products/_table.blade.php deleted file mode 100644 index e0c487c..0000000 --- a/tests/stubs/views/products/_table.blade.php +++ /dev/null @@ -1,48 +0,0 @@ - - - @if(empty($no_bulk)) - - @include('admin.products._bulk') - - @endif - - - - - - - - - - - - - - - - - - - - - @if($products->isEmpty()) - - {{ __('No matching products found.') }} - - @else - @include('admin.products._list') - @endif - - - @if(empty($no_pagination)) - - {{ $products->links('forms::material-admin-26.pagination') }} - - @endif - - diff --git a/tests/stubs/views/products/_titles.blade.stub b/tests/stubs/views/products/_titles.blade.stub deleted file mode 100644 index 653bed5..0000000 --- a/tests/stubs/views/products/_titles.blade.stub +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/tests/stubs/views/products/create.blade.php b/tests/stubs/views/products/create.blade.php deleted file mode 100644 index 811bd4d..0000000 --- a/tests/stubs/views/products/create.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -@extends('admin.products.products') - -@section('page-title', __('New Product')) - -@section('content') - - @include('admin.products._form') - -@endsection diff --git a/tests/stubs/views/products/edit.blade.php b/tests/stubs/views/products/edit.blade.php deleted file mode 100644 index e7837f9..0000000 --- a/tests/stubs/views/products/edit.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -@extends('admin.products.products') - -@section('page-title', __('Edit Product')) - -@section('content') - - @include('admin.products._form') - -@endsection diff --git a/tests/stubs/views/products/index.blade.php b/tests/stubs/views/products/index.blade.php deleted file mode 100644 index b7eae03..0000000 --- a/tests/stubs/views/products/index.blade.php +++ /dev/null @@ -1,36 +0,0 @@ -@extends('admin.products.products') - -@section('page-title') - {{ if_route('admin.products.trash') ? __('Deleted Products') : __('Products') }} -@endsection - -@section('page-subheading') - {{ $title }} -@endsection - -@section('content') - @if($products->isNotEmpty() || App\Models\Product::exists()) -
- - @include('admin.products._filter') - - - @include('admin.products._table', [ - 'no_bulk' => $trashed, - 'no_checkbox' => $trashed, - ]) -
- @else - - @endif -@endsection diff --git a/tests/stubs/views/products/products.blade.php b/tests/stubs/views/products/products.blade.php deleted file mode 100644 index 9cd39e4..0000000 --- a/tests/stubs/views/products/products.blade.php +++ /dev/null @@ -1,12 +0,0 @@ -@extends('layouts.admin') - -@section('title', 'Products') -@section('page-title', __('Products')) - -@section('top-search') - -@endsection - -@section('model-actions') - @include('admin.products._actions') -@endsection diff --git a/tests/stubs/views/products/show.blade.php b/tests/stubs/views/products/show.blade.php deleted file mode 100644 index 7001497..0000000 --- a/tests/stubs/views/products/show.blade.php +++ /dev/null @@ -1,7 +0,0 @@ -@extends('admin.products.products') - -@section('page-title', __('View Product')) - -@section('content') - @include('admin.products._details') -@endsection diff --git a/workbench/.prettierrc.json b/workbench/.prettierrc.json new file mode 100644 index 0000000..afa7d06 --- /dev/null +++ b/workbench/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "semi": false, + "printWidth": 120 +} diff --git a/workbench/app/Enums/ProductTypes.php b/workbench/app/Enums/ProductTypes.php new file mode 100644 index 0000000..4fba30d --- /dev/null +++ b/workbench/app/Enums/ProductTypes.php @@ -0,0 +1,17 @@ + __('Physical'), + self::Digital => __('Digital'), + }; + } +} diff --git a/workbench/app/Http/Controllers/Controller.php b/workbench/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/workbench/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ + */ + use HasFactory; + + use HasRoles; + use InteractsWithMedia; + use OwnsMedia; + + protected $fillable = ['name', 'email', 'password']; + + protected $hidden = ['password', 'remember_token']; + + public function registerMediaCollections(): void + { + $this->registerMediapickerCollections(); + } + + public function registerMediaConversions(?Media $media = null): void + { + $this->registerMediapickerConversions($media); + } + + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } +} diff --git a/tests/stubs/Providers/AppServiceProvider.php b/workbench/app/Providers/AppServiceProvider.php similarity index 100% rename from tests/stubs/Providers/AppServiceProvider.php rename to workbench/app/Providers/AppServiceProvider.php diff --git a/workbench/build.php b/workbench/build.php new file mode 100644 index 0000000..8b0d0be --- /dev/null +++ b/workbench/build.php @@ -0,0 +1,81 @@ + + */ +class UserFactory extends Factory +{ + protected $model = User::class; + + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => 'password', + 'remember_token' => Str::random(10), + ]; + } +} diff --git a/workbench/database/migrations/0001_01_01_000000_create_users_table.php b/workbench/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..3bea822 --- /dev/null +++ b/workbench/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,43 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sessions'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('users'); + } +}; diff --git a/workbench/database/migrations/2026_01_01_000000_create_media_table.php b/workbench/database/migrations/2026_01_01_000000_create_media_table.php new file mode 100644 index 0000000..47a4be9 --- /dev/null +++ b/workbench/database/migrations/2026_01_01_000000_create_media_table.php @@ -0,0 +1,32 @@ +id(); + + $table->morphs('model'); + $table->uuid()->nullable()->unique(); + $table->string('collection_name'); + $table->string('name'); + $table->string('file_name'); + $table->string('mime_type')->nullable(); + $table->string('disk'); + $table->string('conversions_disk')->nullable(); + $table->unsignedBigInteger('size'); + $table->json('manipulations'); + $table->json('custom_properties'); + $table->json('generated_conversions'); + $table->json('responsive_images'); + $table->unsignedInteger('order_column')->nullable()->index(); + + $table->nullableTimestamps(); + }); + } +}; diff --git a/workbench/database/migrations/2026_01_01_000001_create_catalog_tables.php b/workbench/database/migrations/2026_01_01_000001_create_catalog_tables.php new file mode 100644 index 0000000..615e299 --- /dev/null +++ b/workbench/database/migrations/2026_01_01_000001_create_catalog_tables.php @@ -0,0 +1,79 @@ +id(); + $table->string('name')->unique(); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::create('brands', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('website')->nullable(); + $table->timestamps(); + }); + + Schema::create('products', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('sku', 20)->unique(); + $table->text('description')->nullable(); + $table->decimal('price', 10, 2)->unsigned(); + $table->unsignedInteger('stock'); + $table->enum('status', ['draft', 'published']); + $table->string('type')->comment('enum:App\\Enums\\ProductTypes'); + $table->date('released_on')->nullable(); + $table->dateTime('published_at')->nullable(); + $table->time('sale_time')->nullable(); + $table->json('features')->nullable(); + $table->year('manufactured_year')->nullable(); + $table->boolean('is_featured')->default(false); + $table->foreignId('category_id')->constrained()->cascadeOnDelete(); + $table->foreignId('brand_id')->nullable()->constrained()->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('tags', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('product_tag', function (Blueprint $table) { + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('tag_id')->constrained()->cascadeOnDelete(); + $table->primary(['product_id', 'tag_id']); + }); + + Schema::create('reviews', function (Blueprint $table) { + $table->id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->text('body'); + $table->unsignedTinyInteger('rating'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('reviews'); + Schema::dropIfExists('product_tag'); + Schema::dropIfExists('tags'); + Schema::dropIfExists('products'); + Schema::dropIfExists('brands'); + Schema::dropIfExists('categories'); + } +}; diff --git a/workbench/database/migrations/2026_01_01_000002_create_posts_table.php b/workbench/database/migrations/2026_01_01_000002_create_posts_table.php new file mode 100644 index 0000000..bde99db --- /dev/null +++ b/workbench/database/migrations/2026_01_01_000002_create_posts_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('title'); + $table->text('excerpt')->nullable(); + $table->dateTime('published_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('posts'); + } +}; diff --git a/workbench/database/migrations/2026_01_01_000003_create_articles_table.php b/workbench/database/migrations/2026_01_01_000003_create_articles_table.php new file mode 100644 index 0000000..08f940d --- /dev/null +++ b/workbench/database/migrations/2026_01_01_000003_create_articles_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('title'); + $table->string('slug')->unique(); + $table->text('body'); + $table->json('translations')->nullable(); + $table->string('lang')->nullable()->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('articles'); + } +}; diff --git a/workbench/database/migrations/2026_01_01_000004_create_customers_table.php b/workbench/database/migrations/2026_01_01_000004_create_customers_table.php new file mode 100644 index 0000000..6611c46 --- /dev/null +++ b/workbench/database/migrations/2026_01_01_000004_create_customers_table.php @@ -0,0 +1,24 @@ +string('phone')->nullable(); + $table->date('date_of_birth')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('customers'); + } +}; diff --git a/workbench/generate/GenerateAppTest.php b/workbench/generate/GenerateAppTest.php new file mode 100644 index 0000000..d9fc281 --- /dev/null +++ b/workbench/generate/GenerateAppTest.php @@ -0,0 +1,62 @@ +}> + */ + public static function commands(): array + { + return [ + // Blade, plain + ['generate:all', ['table' => 'categories']], + ['generate:all', ['table' => 'tags']], + // Inertia React + ['generate:all', ['table' => 'brands', '--views' => 'inertia']], + // Blade: soft deletes, enums, every column type, a many-to-many relation + ['generate:all', ['table' => 'products']], + // Inertia React, nested under products + ['generate:all', ['table' => 'reviews', '--parent' => 'products', '--views' => 'inertia']], + // Media collections (javaabu/mediapicker), Blade and Inertia React + ['generate:all', ['table' => 'posts', '--media' => 'featured_image:image,documents:document:multiple']], + // Translatable when javaabu/translatable is installed (the translations and lang columns) + ['generate:all', ['table' => 'articles']], + // A javaabu/auth user type and the admin pages of its users + ['generate:auth', ['table' => 'customers']], + ]; + } + + #[Test] + public function it_generates_the_app(): void + { + foreach (static::commands() as [$command, $arguments]) { + $exit = Artisan::call($command, [...$arguments, '--create' => true, '--force' => true]); + + fwrite(STDOUT, "\n> {$command} " . json_encode($arguments, JSON_UNESCAPED_SLASHES) . "\n" . Artisan::output()); + + $this->assertEquals(0, $exit, "{$command} failed"); + } + } +} diff --git a/workbench/phpunit.generate.xml b/workbench/phpunit.generate.xml new file mode 100644 index 0000000..ce2b33e --- /dev/null +++ b/workbench/phpunit.generate.xml @@ -0,0 +1,12 @@ + + + + + generate + + + diff --git a/workbench/phpunit.xml b/workbench/phpunit.xml new file mode 100644 index 0000000..ed92d5f --- /dev/null +++ b/workbench/phpunit.xml @@ -0,0 +1,13 @@ + + + + + ../build/app/tests/Feature + + + diff --git a/workbench/resources/views/app.blade.php b/workbench/resources/views/app.blade.php new file mode 100644 index 0000000..9b8697a --- /dev/null +++ b/workbench/resources/views/app.blade.php @@ -0,0 +1,10 @@ + + + + + @inertiaHead + + + @inertia + + diff --git a/workbench/resources/views/layouts/admin.blade.php b/workbench/resources/views/layouts/admin.blade.php new file mode 100644 index 0000000..ab0f714 --- /dev/null +++ b/workbench/resources/views/layouts/admin.blade.php @@ -0,0 +1,20 @@ + + + + + + + @yield('title') - {{ config('app.name') }} + + + @if(session('status')) + {{ session('status') }} + @endif + +
+ @yield('content') +
+ + @stack('scripts') + + diff --git a/workbench/routes/web.php b/workbench/routes/web.php new file mode 100644 index 0000000..72ea86d --- /dev/null +++ b/workbench/routes/web.php @@ -0,0 +1,5 @@ + 'ok'); diff --git a/workbench/tests/Feature/GeneratedUserTypeTest.php b/workbench/tests/Feature/GeneratedUserTypeTest.php new file mode 100644 index 0000000..8d92272 --- /dev/null +++ b/workbench/tests/Feature/GeneratedUserTypeTest.php @@ -0,0 +1,49 @@ +assertEquals('customer', $type->guardName()); + $this->assertEquals(Customer::class, $type->modelClass()); + $this->assertEquals('customer', UserTypes::for(new Customer)->name()); + } + + #[Test] + public function it_shows_the_login_page_of_the_user_type(): void + { + $this->get(route('customer.login')) + ->assertOk(); + } + + #[Test] + public function it_logs_customers_in_on_their_guard(): void + { + $customer = Customer::factory()->create(['email' => 'ali@example.com']); + + $this->post(route('customer.login.store'), [ + 'email' => 'ali@example.com', + 'password' => 'password', + ])->assertRedirect(); + + $this->assertAuthenticatedAs($customer, 'customer'); + $this->assertGuest('web'); + } +} diff --git a/workbench/tests/TestCase.php b/workbench/tests/TestCase.php new file mode 100644 index 0000000..ed863e0 --- /dev/null +++ b/workbench/tests/TestCase.php @@ -0,0 +1,79 @@ +set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); + $config->set('app.locale', 'en'); + + $config->set('database.default', 'testing'); + $config->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ]); + + $config->set('session.driver', 'array'); + $config->set('cache.default', 'array'); + $config->set('queue.default', 'sync'); + + $config->set('auth.providers.users.model', 'App\\Models\\User'); + + $config->set('permission.cache.store', 'array'); + + $config->set('inertia.testing.ensure_pages_exist', true); + $config->set('inertia.testing.page_paths', [resource_path('js/pages')]); + $config->set('inertia.testing.page_extensions', ['tsx']); + + $config->set('media-library.media_model', 'Javaabu\\Mediapicker\\Models\\Media'); + $config->set('media-library.disk_name', 'public'); + + $config->set('generators.enums', [ + 'products.type' => 'App\\Enums\\ProductTypes', + ]); + }); + } + + protected function defineRoutes($router): void + { + // routes/web.php loads the admin routes the generators write + /** @var Router $router */ + $router->group([], base_path('routes/web.php')); + } + + protected function defineDatabaseMigrations(): void + { + $this->loadMigrationsFrom(database_path('migrations')); + } +} diff --git a/workbench/tsconfig.json b/workbench/tsconfig.json new file mode 100644 index 0000000..0f485fb --- /dev/null +++ b/workbench/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"], + "paths": { + "@javaabu/stencil": ["../vendor/javaabu/stencil/resources/js"], + "@javaabu/stencil/*": ["../vendor/javaabu/stencil/resources/js/*"], + "@javaabu/filters": ["../vendor/javaabu/filters/resources/js"], + "@javaabu/filters/*": ["../vendor/javaabu/filters/resources/js/*"], + "@javaabu/bulk-actions": ["../vendor/javaabu/bulk-actions/resources/js"], + "@javaabu/bulk-actions/*": ["../vendor/javaabu/bulk-actions/resources/js/*"], + "@javaabu/mediapicker": ["./types/mediapicker.d.ts"] + } + }, + "include": ["../build/app/resources/js/pages", "./types"] +} diff --git a/workbench/types/mediapicker.d.ts b/workbench/types/mediapicker.d.ts new file mode 100644 index 0000000..89ccbac --- /dev/null +++ b/workbench/types/mediapicker.d.ts @@ -0,0 +1,5 @@ +// @javaabu/mediapicker's React code pulls in the block editor and the cropper; +// the generated pages only pass its stencil component to . +import type { StencilComponent } from "@javaabu/stencil" + +export declare const StencilMediaPicker: StencilComponent diff --git a/workbench/types/vite.d.ts b/workbench/types/vite.d.ts new file mode 100644 index 0000000..7e94499 --- /dev/null +++ b/workbench/types/vite.d.ts @@ -0,0 +1,5 @@ +// stencil's styles import CSS as a string through Vite +declare module "*.css?raw" { + const css: string + export default css +}